diff --git a/CHANGELOG.md b/CHANGELOG.md index 117c515..39a1632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. prefix, JSON encoding and `default_ttl` as `set()`, and runs on the backend's atomic `set_if_absent`, so of several concurrent callers exactly one wins — for "send this webhook once" style deduplication. ([#65](https://github.com/allen0099/FastAPI-CacheX/issues/65)) +- `add_routes(..., include_content_preview=False)` leaves response bodies out + of `/cached-records`: `content_preview` is `null`, while keys, sizes and expiry + are still reported. The default stays `True`. ([#79](https://github.com/allen0099/FastAPI-CacheX/issues/79)) ## [0.3.6] - 2026-09-25 diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 79354a6..0425abc 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -248,6 +248,7 @@ add_routes( prefix="/admin/cache", # default "" -> /cached-hits, /cached-records include_in_schema=False, # default: hidden from OpenAPI dependencies=[Depends(verify_admin)], + include_content_preview=False, # default True: show the first 100 bytes ) ``` @@ -255,14 +256,17 @@ add_routes( and query, with its ETag and expiry, plus counts of valid and expired entries and the distinct cached paths. It does not count hits. - `GET {prefix}/cached-records` — every cached record with its size, expiry and - a preview of the cached content. + a preview of the first 100 bytes of the cached content. With + `include_content_preview=False`, `content_preview` is `null` and no response + body leaves the server; keys, sizes and expiry are still reported. > [!WARNING] > **These routes have no authentication of their own.** `include_in_schema=False` > only hides them from the OpenAPI document; anyone who guesses the path can read -> them. `/cached-records` includes a preview of the cached content and exposes -> your whole route structure. In production always pass -> `dependencies=[Depends(your_auth)]`, or mount them on an internal-only app. +> them. `/cached-records` includes a preview of the cached content (unless +> `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. > [!NOTE] > The `ttl_remaining` field is not available on the Redis backend. diff --git a/fastapi_cachex/routes.py b/fastapi_cachex/routes.py index 9a8a8c7..ce15686 100644 --- a/fastapi_cachex/routes.py +++ b/fastapi_cachex/routes.py @@ -69,7 +69,7 @@ class CachedRecord: content_size: int is_expired: bool ttl_remaining: float | None - content_preview: str + content_preview: str | None @dataclass @@ -192,7 +192,9 @@ def _cached_hits(entries: list[_Entry]) -> CacheHitsResponse: ) -def _cached_records(entries: list[_Entry]) -> CachedRecordsResponse: +def _cached_records( + entries: list[_Entry], include_content_preview: bool +) -> CachedRecordsResponse: cached_records = [ CachedRecord( cache_key=e.cache_key, @@ -205,8 +207,10 @@ def _cached_records(entries: list[_Entry]) -> CachedRecordsResponse: content_size=len(e.entry.content), is_expired=e.is_expired, ttl_remaining=e.ttl_remaining, - content_preview=e.entry.content[:_PREVIEW_BYTES].decode( - "utf-8", errors="ignore" + content_preview=( + e.entry.content[:_PREVIEW_BYTES].decode("utf-8", errors="ignore") + if include_content_preview + else None ), ) for e in entries @@ -233,6 +237,7 @@ def add_routes( prefix: str = "", include_in_schema: bool = False, dependencies: Sequence[Any] | None = None, + include_content_preview: bool = True, ) -> None: """Add cache monitoring routes to the FastAPI application. @@ -250,6 +255,10 @@ def add_routes( all monitoring routes. Useful for adding authentication or authorization guards (e.g. ``[Depends(verify_api_key)]``). + 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: from fastapi import FastAPI @@ -292,4 +301,6 @@ async def get_cached_records() -> CachedRecordsResponse: Returns: CachedRecordsResponse containing cached records and statistics """ - return _cached_records(_parse_entries(await _cache_data())) + return _cached_records( + _parse_entries(await _cache_data()), include_content_preview + ) diff --git a/tests/test_routes.py b/tests/test_routes.py index b238747..d8e818a 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -352,6 +352,29 @@ async def large_endpoint(): record = data["cached_records"][0] assert len(record["content_preview"]) == 100 + 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) + + @app.get("/api/secret") + @cache(ttl=60) + async def get_secret(): + return Response( + content=b'{"token": "s3cr3t"}', media_type="application/json" + ) + + client.get("/api/secret") + + response = client.get("/cached-records") + assert response.status_code == 200 + (record,) = response.json()["cached_records"] + assert record["content_preview"] is None + assert "s3cr3t" not in response.text + # Everything but the body is still reported. + assert record["path"] == "/api/secret" + assert record["content_size"] == len(b'{"token": "s3cr3t"}') + assert record["ttl_remaining"] is not None + def test_cached_records_summary_calculations(self, app, client, setup_cache): """Test that summary calculations are correct.""" add_routes(app)