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
8 changes: 8 additions & 0 deletions changelog.d/301.deprecated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**Calling `add_routes()` without `dependencies`.** The monitoring routes have
no access control of their own and expose every cached key (including query
strings) and response previews, so leaving `dependencies` unset now emits a
`UserWarning`. 0.4.0 will require the parameter and turn
`include_content_preview` off by default
([#298](https://github.com/allen0099/FastAPI-CacheX/issues/298)). Pass a guard
such as `dependencies=[Depends(verify_admin)]`, or `dependencies=[]` to keep
the routes open on purpose without the warning.
6 changes: 6 additions & 0 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,12 @@ add_routes(
> `include_content_preview=False`) and exposes your whole route structure. In
> production always pass `dependencies=[Depends(your_auth)]`, or mount them on
> an internal-only app.
>
> Calling `add_routes()` without `dependencies` emits a `UserWarning`. Version
> 0.4.0 will require the parameter and turn `include_content_preview` off by
> default ([#298](https://github.com/allen0099/FastAPI-CacheX/issues/298)). For
> a local or test app that should stay open, pass `dependencies=[]` to opt out
> deliberately without the warning.

> [!NOTE]
> On Memcached, which cannot enumerate keys, both routes return nothing.
37 changes: 27 additions & 10 deletions fastapi_cachex/routes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Optional routes for cache monitoring and management."""

import time
import warnings
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -261,35 +262,51 @@ def add_routes(
Mounts two read-only routes that report what the configured backend
currently holds. They inspect stored entries; nothing counts cache hits.
The routes have no authentication of their own, so pass ``dependencies``
in production.
in production. Leaving ``dependencies`` unset (``None``) emits a
``UserWarning``: 0.4.0 will require it. Pass ``dependencies=[]`` to mount
the routes unguarded on purpose (local or test setups) without the
warning.

Args:
app: FastAPI application instance
prefix: URL prefix for the routes (e.g., "/api/cache", "/admin/cache").
Defaults to "" (no prefix).
include_in_schema: Whether to include routes in OpenAPI schema.
Defaults to False.
dependencies: Optional list of FastAPI ``Depends`` objects applied to
all monitoring routes. Useful for adding authentication
or authorization guards (e.g.
``[Depends(verify_api_key)]``).
dependencies: FastAPI ``Depends`` objects applied to all monitoring
routes, for authentication or authorization guards
(e.g. ``[Depends(verify_api_key)]``). ``None`` (the
default) mounts the routes unguarded and emits a
``UserWarning``; an explicit ``[]`` does the same
without the warning.
include_content_preview: Whether ``/cached-records`` includes the first
bytes of each cached response body. When False,
``content_preview`` is ``null`` while keys, sizes and
expiry are still reported. Defaults to True.

Example:
```python
from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi_cachex import add_routes

app = FastAPI()
add_routes(app) # Routes at /cached-hits and /cached-records

# Or with a prefix: /api/cache/cached-hits and /api/cache/cached-records
add_routes(app, prefix="/api/cache")
# Routes at /api/cache/cached-hits and /api/cache/cached-records,
# guarded by your own dependency
add_routes(app, prefix="/api/cache", dependencies=[Depends(verify_admin)])
```
"""
if dependencies is None:
warnings.warn(
"add_routes() is mounting the cache monitoring routes without access "
"control: anyone who can reach the app can read every cached key "
"(including query strings) and previews of the cached responses. "
"Pass dependencies=[Depends(your_auth)] to guard them, or "
"dependencies=[] to opt out deliberately. Version 0.4.0 will require "
"dependencies and turn include_content_preview off by default "
"(https://github.com/allen0099/FastAPI-CacheX/issues/298).",
UserWarning,
stacklevel=2,
)

@app.get(
f"{prefix}/cached-hits",
Expand Down
2 changes: 2 additions & 0 deletions i18n/zh-TW/docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ add_routes(

> [!WARNING]
> **這些路由本身沒有任何身分驗證。** `include_in_schema=False` 只是讓它們不出現在 OpenAPI 文件中;任何猜到路徑的人都能讀取。`/cached-records` 含有快取內容的預覽(除非設定 `include_content_preview=False`),並會暴露整個路由結構。正式環境中請務必傳入 `dependencies=[Depends(your_auth)]`,或將它們掛載在僅供內部使用的應用程式上。
>
> 呼叫 `add_routes()` 時若未傳入 `dependencies`,會發出 `UserWarning`。0.4.0 版將要求必須傳入此參數,並將 `include_content_preview` 預設改為關閉([#298](https://github.com/allen0099/FastAPI-CacheX/issues/298))。若本機或測試用的應用程式確實要保持開放,請傳入 `dependencies=[]` 明確選擇不設防護,這樣就不會出現警告。

> [!NOTE]
> Memcached 無法列舉鍵,因此在 Memcached 上這兩個路由都不會回傳任何內容。
96 changes: 68 additions & 28 deletions tests/test_routes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for cache monitoring routes."""

import time
import warnings

import pytest
from fastapi import FastAPI
Expand Down Expand Up @@ -43,7 +44,7 @@ class TestCachedHitsRoute:

def test_cached_hits_without_backend(self, app, client):
"""Test /cached-hits returns empty when backend not configured."""
add_routes(app)
add_routes(app, dependencies=[])

response = client.get("/cached-hits")
assert response.status_code == 200
Expand All @@ -55,7 +56,7 @@ def test_cached_hits_without_backend(self, app, client):

def test_cached_hits_empty_cache(self, app, client, setup_cache):
"""Test /cached-hits returns empty structure when cache is empty."""
add_routes(app)
add_routes(app, dependencies=[])

response = client.get("/cached-hits")
assert response.status_code == 200
Expand All @@ -67,7 +68,7 @@ def test_cached_hits_empty_cache(self, app, client, setup_cache):

def test_cached_hits_with_entries(self, app, client, setup_cache):
"""Test /cached-hits returns cached entries when routes are cached."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/api/users")
@cache(ttl=60)
Expand Down Expand Up @@ -110,7 +111,7 @@ async def get_products():

def test_cached_hits_route_structure(self, app, client, setup_cache):
"""Test that cached hit records have correct structure."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/api/test")
@cache(ttl=60)
Expand Down Expand Up @@ -146,7 +147,7 @@ async def test_endpoint():

def test_cached_hits_with_prefix(self, app, client, setup_cache):
"""Test /cached-hits route with custom prefix."""
add_routes(app, prefix="/admin/cache")
add_routes(app, prefix="/admin/cache", dependencies=[])

@app.get("/test")
@cache(ttl=60)
Expand All @@ -162,7 +163,7 @@ async def test_endpoint():

def test_cached_hits_multiple_query_variations(self, app, client, setup_cache):
"""Test /cached-hits shows different cache keys for query params."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/api/items") # type: ignore[untyped-decorator]
@cache(ttl=60)
Expand Down Expand Up @@ -192,7 +193,7 @@ class TestCachedRecordsRoute:

def test_cached_records_without_backend(self, app, client):
"""Test /cached-records returns empty when backend not configured."""
add_routes(app)
add_routes(app, dependencies=[])

response = client.get("/cached-records")
assert response.status_code == 200
Expand All @@ -203,7 +204,7 @@ def test_cached_records_without_backend(self, app, client):

def test_cached_records_empty_cache(self, app, client, setup_cache):
"""Test /cached-records returns empty structure when cache is empty."""
add_routes(app)
add_routes(app, dependencies=[])

response = client.get("/cached-records")
assert response.status_code == 200
Expand All @@ -215,7 +216,7 @@ def test_cached_records_empty_cache(self, app, client, setup_cache):

def test_cached_records_with_entries(self, app, client, setup_cache):
"""Test /cached-records returns cached entries with content info."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/api/users")
@cache(ttl=60)
Expand Down Expand Up @@ -248,7 +249,7 @@ async def get_products():

def test_cached_records_structure(self, app, client, setup_cache):
"""Test that cached records have correct structure."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/api/test")
@cache(ttl=60)
Expand Down Expand Up @@ -286,7 +287,7 @@ async def test_endpoint():

def test_cached_records_reports_media_type(self, app, client, setup_cache):
"""``media_type`` is the stored response's, ``content_type`` stays "bytes"."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/api/json")
@cache(ttl=60)
Expand All @@ -310,7 +311,7 @@ async def text_endpoint():

def test_cached_records_media_type_null_when_unset(self, app, client, setup_cache):
"""An entry stored without a media type reports ``null``."""
add_routes(app)
add_routes(app, dependencies=[])
setup_cache.cache["GET|||h|||/raw|||"] = CacheItem(
value=CacheEntry(fingerprint="e", content=b"x"), expiry=None
)
Expand All @@ -320,7 +321,7 @@ def test_cached_records_media_type_null_when_unset(self, app, client, setup_cach

def test_cached_records_content_size_calculation(self, app, client, setup_cache):
"""Test that content size is calculated correctly."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/api/small")
@cache(ttl=60)
Expand Down Expand Up @@ -351,7 +352,7 @@ async def large_endpoint():

def test_cached_records_with_prefix(self, app, client, setup_cache):
"""Test /cached-records route with custom prefix."""
add_routes(app, prefix="/api/cache")
add_routes(app, prefix="/api/cache", dependencies=[])

@app.get("/test")
@cache(ttl=60)
Expand All @@ -367,7 +368,7 @@ async def test_endpoint():

def test_cached_records_content_preview(self, app, client, setup_cache):
"""Test that content preview is limited to 100 bytes."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/api/large")
@cache(ttl=60)
Expand All @@ -388,7 +389,7 @@ async def large_endpoint():

def test_cached_records_can_omit_content_preview(self, app, client, setup_cache):
"""include_content_preview=False hides bodies but keeps the metadata."""
add_routes(app, include_content_preview=False)
add_routes(app, include_content_preview=False, dependencies=[])

@app.get("/api/secret")
@cache(ttl=60)
Expand All @@ -411,7 +412,7 @@ async def get_secret():

def test_cached_records_summary_calculations(self, app, client, setup_cache):
"""Test that summary calculations are correct."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/api/test1")
@cache(ttl=60)
Expand Down Expand Up @@ -442,7 +443,7 @@ class TestRoutesIntegration:

def test_routes_without_prefix(self, app, client, setup_cache):
"""Test that routes work without prefix."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/test")
@cache(ttl=60)
Expand All @@ -459,7 +460,7 @@ async def test_endpoint():

def test_routes_consistency(self, app, client, setup_cache):
"""Test that both routes show consistent data."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/api/consistent")
@cache(ttl=60)
Expand All @@ -485,7 +486,7 @@ async def consistent_endpoint():

def test_routes_not_cached_by_default(self, app, client, setup_cache):
"""Test that the monitoring routes themselves are not cached."""
add_routes(app)
add_routes(app, dependencies=[])

@app.get("/api/test")
@cache(ttl=60)
Expand All @@ -504,7 +505,7 @@ async def test_endpoint():

def test_include_in_schema_parameter(self, app):
"""Test that include_in_schema parameter works."""
add_routes(app, include_in_schema=True)
add_routes(app, include_in_schema=True, dependencies=[])

# Check if routes are included in OpenAPI schema
openapi_schema = app.openapi()
Expand Down Expand Up @@ -541,13 +542,52 @@ def require_api_key(x_api_key: str | None = Header(default=None)) -> None:
r2 = dep_client.get("/cached-hits", headers={"x-api-key": "secret"})
assert r2.status_code == 200

def test_add_routes_with_none_dependencies_no_error(self, app, client, setup_cache):
"""Passing dependencies=None (default) must not raise errors."""
add_routes(app, dependencies=None)
def test_add_routes_with_none_dependencies_warns_and_mounts(
self, app, client, setup_cache
):
"""dependencies=None warns but still mounts the routes unguarded."""
with pytest.warns(UserWarning, match="dependencies"):
add_routes(app, dependencies=None)
response = client.get("/cached-hits")
assert response.status_code == 200


class TestUnguardedWarning:
"""add_routes() warns when mounted without dependencies (#301)."""

def test_default_warns(self, app):
"""Leaving dependencies unset emits a UserWarning naming it."""
with pytest.warns(UserWarning, match="without access control") as record:
add_routes(app)

assert len(record) == 1
message = str(record[0].message)
assert "dependencies" in message
assert "dependencies=[]" in message
assert "0.4.0" in message
assert "include_content_preview" in message
assert "298" in message
# stacklevel points at the caller, not at routes.py
assert record[0].filename == __file__

def test_empty_dependencies_does_not_warn(self, app):
"""An explicit dependencies=[] is a deliberate opt-out."""
with warnings.catch_warnings():
warnings.simplefilter("error")
add_routes(app, dependencies=[])

def test_guarded_does_not_warn(self, app):
"""Passing a real guard does not warn."""
from fastapi import Depends

def guard() -> None:
return None

with warnings.catch_warnings():
warnings.simplefilter("error")
add_routes(app, dependencies=[Depends(guard)])


def _report_expired(backend: MemoryBackend, key: str, entry: CacheEntry) -> None:
"""Make ``get_cache_data`` return an entry whose expiry has passed.

Expand All @@ -567,7 +607,7 @@ class TestExpiredEntryMonitoring:

def test_cached_hits_shows_expired_entry(self, app, client, setup_cache):
"""/cached-hits marks is_expired=True for entries whose TTL has passed."""
add_routes(app)
add_routes(app, dependencies=[])

# TestClient sends Host: testserver by default
cache_key = "GET|||testserver|||/expired-route|||"
Expand All @@ -589,7 +629,7 @@ def test_cached_hits_shows_expired_entry(self, app, client, setup_cache):

def test_cached_records_shows_expired_entry(self, app, client, setup_cache):
"""/cached-records marks is_expired=True for entries whose TTL has passed."""
add_routes(app)
add_routes(app, dependencies=[])

cache_key = "GET|||testserver|||/expired-data|||"
expired_entry = CacheEntry(
Expand All @@ -614,7 +654,7 @@ class TestMonitoringEdgeCases:

def test_non_route_keys_are_skipped(self, app, client, setup_cache):
"""A CacheManager/state key has no method|||host|||path shape and must not be listed."""
add_routes(app)
add_routes(app, dependencies=[])
setup_cache.cache["cache:plain-value"] = CacheItem(
value=CacheEntry(fingerprint="x", content=b"1"), expiry=None
)
Expand All @@ -629,7 +669,7 @@ def test_non_route_keys_are_skipped(self, app, client, setup_cache):
assert [r["path"] for r in records["cached_records"]] == ["/route"]

def test_routes_answer_empty_when_no_backend_is_configured(self, app, client):
add_routes(app)
add_routes(app, dependencies=[])
BackendProxy.set(None)

hits = client.get("/cached-hits").json()
Expand Down
Loading