diff --git a/changelog.d/298.changed.md b/changelog.d/298.changed.md new file mode 100644 index 0000000..e56042d --- /dev/null +++ b/changelog.d/298.changed.md @@ -0,0 +1,8 @@ +**`add_routes()` requires `dependencies`, and content previews are off by +default.** The monitoring routes have no authentication of their own, so +`dependencies` is now a required keyword-only argument: pass your guard, or +`dependencies=[]` to mount the routes unguarded on purpose. Leaving it out, or +passing `None`, raises `TypeError`. `include_content_preview` is keyword-only +and defaults to `False`, also for apps that already pass a guard; pass `True` +to keep the first 100 bytes of each cached body in `/cached-records`. See the +[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#add-routes). diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index bd649dc..94c452e 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -754,8 +754,8 @@ add_routes( app, 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 + dependencies=[Depends(verify_admin)], # required, keyword-only + include_content_preview=True, # default False: no body previews ) ``` @@ -764,10 +764,10 @@ add_routes( 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, - `media_type` (the stored response's media type, `null` if it had none) and 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. + `media_type` (the stored response's media type, `null` if it had none) and, + with `include_content_preview=True`, a preview of the first 100 bytes of the + cached content. By default `content_preview` is `null` and no response body + leaves the server; keys, sizes and expiry are still reported. `content_type` is always `"bytes"` and is kept for compatibility; read `media_type` instead. @@ -778,16 +778,11 @@ keys from a `key_builder` that does not use `build_cache_key()`. > [!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 (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. -> -> 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. +> them. They expose your whole route structure, including query strings, and +> with `include_content_preview=True` the start of every cached response. So +> `dependencies` is required: pass `dependencies=[Depends(your_auth)]`, or mount +> the routes on an internal-only app. For a local or test app that should stay +> open, pass `dependencies=[]` to opt out deliberately. The runnable example guards them with a token from an environment variable, and keeps them closed while the variable is unset: diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index 0dfbbf8..ae8ebe0 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -32,7 +32,7 @@ Every warning below names the setting to change and links to its issue. `FutureW | `get_or_set()` locks by default | [#280](https://github.com/allen0099/FastAPI-CacheX/issues/280) | `FutureWarning` | [get_or_set lock](#get-or-set-lock) | | `get_session_manager` resolves through `SessionManagerProxy` | [#131](https://github.com/allen0099/FastAPI-CacheX/issues/131) | `FutureWarning` | [get_session_manager](#get-session-manager) | | `token_source_priority` names every token source; a list without `"cookie"` disables the cookie | [#75](https://github.com/allen0099/FastAPI-CacheX/issues/75) | `FutureWarning` | [Token sources](#token-source-priority) | -| `add_routes()` requires `dependencies`, no content preview by default | [#298](https://github.com/allen0099/FastAPI-CacheX/issues/298) | `UserWarning` | [Monitoring routes](#add-routes) | +| `add_routes()` requires `dependencies`, no content preview by default | [#298](https://github.com/allen0099/FastAPI-CacheX/issues/298) | `UserWarning` (only when `dependencies` is left out) | [Monitoring routes](#add-routes) | | Redis `encoding` option removed | [#126](https://github.com/allen0099/FastAPI-CacheX/issues/126) | `DeprecationWarning` (`RuntimeWarning` for a value other than UTF-8) | [Redis encoding](#redis-encoding) | | JWT HMAC secrets shorter than the hash output are rejected | [#129](https://github.com/allen0099/FastAPI-CacheX/issues/129) | `UserWarning` | [JWT secret length](#jwt-secret) | | `SessionMiddleware` removed | [#69](https://github.com/allen0099/FastAPI-CacheX/issues/69) | `DeprecationWarning` | [SessionMiddleware](#session-middleware) | @@ -287,7 +287,7 @@ Redis and Memcached store the headers as a JSON list of `[name, value]` lines. 0 ### Monitoring routes {#add-routes} -`add_routes()` requires `dependencies` in 0.4.0, and `include_content_preview` defaults to `False` ([#298](https://github.com/allen0099/FastAPI-CacheX/issues/298)). 0.3.9 emits a `UserWarning` when `dependencies` is left out. +`add_routes()` requires `dependencies` in 0.4.0, and `include_content_preview` defaults to `False` ([#298](https://github.com/allen0099/FastAPI-CacheX/issues/298)). 0.3.9 emits a `UserWarning` when `dependencies` is left out. `dependencies` and `include_content_preview` are keyword-only; leaving `dependencies` out, or passing `None`, raises `TypeError`. 0.3.9 did not warn about the two cases that break without it: passing these arguments by position, which now raises `TypeError`, and relying on the preview default while passing `dependencies`, which now hides the previews; pass `include_content_preview=True` to keep them. ```python # Before diff --git a/fastapi_cachex/routes.py b/fastapi_cachex/routes.py index 9823f5a..57ed650 100644 --- a/fastapi_cachex/routes.py +++ b/fastapi_cachex/routes.py @@ -1,7 +1,6 @@ """Optional routes for cache monitoring and management.""" import time -import warnings from collections.abc import Sequence from dataclasses import dataclass from dataclasses import field @@ -235,18 +234,17 @@ def add_routes( app: "FastAPI", prefix: str = "", include_in_schema: bool = False, - dependencies: Sequence[Any] | None = None, - include_content_preview: bool = True, + *, + dependencies: Sequence[Any], + include_content_preview: bool = False, ) -> None: """Add cache monitoring routes to the FastAPI application. 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. 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. + The routes have no authentication of their own, so ``dependencies`` is + required: pass your guard, or ``dependencies=[]`` to mount the routes + unguarded on purpose (local or test setups). Args: app: FastAPI application instance @@ -256,14 +254,15 @@ def add_routes( Defaults to False. 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. + (e.g. ``[Depends(verify_api_key)]``). Required and + keyword-only; an empty list mounts the routes unguarded. 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. + 100 bytes of each cached response body. Defaults to + False, which reports ``content_preview`` as ``null`` + while keys, sizes and expiry are still reported. + + Raises: + TypeError: If ``dependencies`` is ``None``. Example: ```python @@ -277,17 +276,14 @@ def add_routes( ``` """ 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, + # Unreachable for type checkers; guards untyped callers. + msg = ( # type: ignore[unreachable] + "add_routes() requires dependencies: pass " + "dependencies=[Depends(your_auth)] to guard the cache monitoring " + "routes, or dependencies=[] to mount them unguarded on purpose " + "(https://github.com/allen0099/FastAPI-CacheX/issues/298)" ) + raise TypeError(msg) @app.get( f"{prefix}/cached-hits", diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 280c975..a9d2870 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -447,20 +447,18 @@ add_routes( app, prefix="/admin/cache", # 預設 "" -> /cached-hits、/cached-records include_in_schema=False, # 預設:不出現在 OpenAPI 中 - dependencies=[Depends(verify_admin)], - include_content_preview=False, # 預設 True:顯示前 100 個位元組 + dependencies=[Depends(verify_admin)], # 必填,只能以關鍵字傳入 + include_content_preview=True, # 預設 False:不含本文預覽 ) ``` - `GET {prefix}/cached-hits`:列出每筆快取項目,拆分為方法、主機、路徑與查詢(超過 200 位元組的查詢為 `sha256:<十六進位>`),附上 ETag 與到期時間,另外統計有效與已過期的項目數,以及不重複的快取路徑。它不會計算命中次數。 -- `GET {prefix}/cached-records`:列出每筆快取紀錄的大小、到期時間、`media_type`(儲存的回應的媒體類型,沒有時為 `null`),以及快取內容前 100 個位元組的預覽。設定 `include_content_preview=False` 時,`content_preview` 為 `null`,不會有任何回應本文離開伺服器;鍵、大小與到期時間仍會回報。`content_type` 一律是 `"bytes"`,只為相容而保留;請改讀 `media_type`。 +- `GET {prefix}/cached-records`:列出每筆快取紀錄的大小、到期時間、`media_type`(儲存的回應的媒體類型,沒有時為 `null`),以及在 `include_content_preview=True` 時快取內容前 100 個位元組的預覽。預設 `content_preview` 為 `null`,不會有任何回應本文離開伺服器;鍵、大小與到期時間仍會回報。`content_type` 一律是 `"bytes"`,只為相容而保留;請改讀 `media_type`。 兩個路由都只列出路由項目(格式為 `http:v2|method|host|path|query` 的鍵);`CacheManager`、Session、state 與鎖的鍵都會略過,未使用 `build_cache_key()` 的 `key_builder` 產生的鍵也一樣。 > [!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=[]` 明確選擇不設防護,這樣就不會出現警告。 +> **這些路由本身沒有任何身分驗證。** `include_in_schema=False` 只是讓它們不出現在 OpenAPI 文件中;任何猜到路徑的人都能讀取。它們會暴露整個路由結構(包含查詢字串),設定 `include_content_preview=True` 時還會暴露每個快取回應的開頭。因此 `dependencies` 為必填:請傳入 `dependencies=[Depends(your_auth)]`,或將路由掛載在僅供內部使用的應用程式上。若本機或測試用的應用程式確實要保持開放,請傳入 `dependencies=[]` 明確選擇不設防護。 可執行範例以環境變數中的權杖保護它們,未設定該變數時路由一律拒絕存取: diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index 4ec452f..cc17021 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -32,7 +32,7 @@ filterwarnings = [ | `get_or_set()` 預設使用鎖 | [#280](https://github.com/allen0099/FastAPI-CacheX/issues/280) | `FutureWarning` | [get_or_set 的鎖](#get-or-set-lock) | | `get_session_manager` 透過 `SessionManagerProxy` 取得 | [#131](https://github.com/allen0099/FastAPI-CacheX/issues/131) | `FutureWarning` | [get_session_manager](#get-session-manager) | | `token_source_priority` 列出所有權杖來源;沒有 `"cookie"` 的清單會停用 Cookie | [#75](https://github.com/allen0099/FastAPI-CacheX/issues/75) | `FutureWarning` | [權杖來源](#token-source-priority) | -| `add_routes()` 必須傳入 `dependencies`,預設不含內容預覽 | [#298](https://github.com/allen0099/FastAPI-CacheX/issues/298) | `UserWarning` | [監控路由](#add-routes) | +| `add_routes()` 必須傳入 `dependencies`,預設不含內容預覽 | [#298](https://github.com/allen0099/FastAPI-CacheX/issues/298) | `UserWarning`(僅在省略 `dependencies` 時) | [監控路由](#add-routes) | | 移除 Redis 的 `encoding` 選項 | [#126](https://github.com/allen0099/FastAPI-CacheX/issues/126) | `DeprecationWarning`(UTF-8 以外的值為 `RuntimeWarning`) | [Redis encoding](#redis-encoding) | | 拒絕短於雜湊輸出的 JWT HMAC 密鑰 | [#129](https://github.com/allen0099/FastAPI-CacheX/issues/129) | `UserWarning` | [JWT 密鑰長度](#jwt-secret) | | 移除 `SessionMiddleware` | [#69](https://github.com/allen0099/FastAPI-CacheX/issues/69) | `DeprecationWarning` | [SessionMiddleware](#session-middleware) | @@ -286,7 +286,7 @@ Redis 與 Memcached 以 `[name, value]` 行組成的 JSON 清單儲存標頭。0 ### 監控路由 {#add-routes} -0.4.0 起 `add_routes()` 必須傳入 `dependencies`,`include_content_preview` 預設為 `False`([#298](https://github.com/allen0099/FastAPI-CacheX/issues/298))。0.3.9 在省略 `dependencies` 時會發出 `UserWarning`。 +0.4.0 起 `add_routes()` 必須傳入 `dependencies`,`include_content_preview` 預設為 `False`([#298](https://github.com/allen0099/FastAPI-CacheX/issues/298))。0.3.9 在省略 `dependencies` 時會發出 `UserWarning`。`dependencies` 與 `include_content_preview` 只能以關鍵字傳入;省略 `dependencies` 或傳入 `None` 會引發 `TypeError`。0.3.9 對以下兩種情況不會發出警告:以位置傳入這些參數,現在會引發 `TypeError`;以及已傳入 `dependencies` 但依賴預覽的預設值,現在預覽會被隱藏,請傳入 `include_content_preview=True` 保留。 ```python # 修改前 diff --git a/tests/test_routes.py b/tests/test_routes.py index 3fe4415..0fc4b82 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,7 +1,6 @@ """Tests for cache monitoring routes.""" import time -import warnings import pytest from fastapi import FastAPI @@ -368,7 +367,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, dependencies=[]) + add_routes(app, dependencies=[], include_content_preview=True) @app.get("/api/large") @cache(ttl=60) @@ -387,9 +386,14 @@ 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, dependencies=[]) + @pytest.mark.parametrize( + "options", [{}, {"include_content_preview": False}], ids=["default", "off"] + ) + def test_cached_records_can_omit_content_preview( + self, app, client, setup_cache, options + ): + """Previews are off by default (#298); keys, sizes and expiry remain.""" + add_routes(app, dependencies=[], **options) @app.get("/api/secret") @cache(ttl=60) @@ -542,50 +546,40 @@ 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_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 TestDependenciesRequired: + """add_routes() requires dependencies (#298).""" + + def test_leaving_dependencies_out_raises(self, app): + """Omitting dependencies fails when the app is set up.""" + with pytest.raises(TypeError, match="dependencies"): + add_routes(app) # type: ignore[call-arg] -class TestUnguardedWarning: - """add_routes() warns when mounted without dependencies (#301).""" + def test_none_raises(self, app): + """An untyped caller cannot pass None to skip the choice.""" + with pytest.raises(TypeError, match=r"dependencies=\[\]") as info: + add_routes(app, dependencies=None) # type: ignore[arg-type] - 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 "issues/298" in str(info.value) + assert "/cached-hits" not in {r.path for r in app.routes} - 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_dependencies_is_keyword_only(self, app): + """A 0.3.x positional call fails instead of mounting unguarded.""" + with pytest.raises(TypeError, match="positional"): + add_routes(app, "", False, []) # type: ignore[call-arg] # noqa: FBT003 - def test_empty_dependencies_does_not_warn(self, app): + def test_empty_dependencies_mounts_unguarded(self, app, client, setup_cache): """An explicit dependencies=[] is a deliberate opt-out.""" - with warnings.catch_warnings(): - warnings.simplefilter("error") - add_routes(app, dependencies=[]) + add_routes(app, dependencies=[]) - def test_guarded_does_not_warn(self, app): - """Passing a real guard does not warn.""" - from fastapi import Depends + assert client.get("/cached-hits").status_code == 200 - def guard() -> None: - return None + def test_prefix_and_include_in_schema_stay_positional(self, app, client): + """Only the arguments after include_in_schema became keyword-only.""" + add_routes(app, "/admin", True, dependencies=[]) # noqa: FBT003 - with warnings.catch_warnings(): - warnings.simplefilter("error") - add_routes(app, dependencies=[Depends(guard)]) + assert client.get("/admin/cached-hits").status_code == 200 + assert "/admin/cached-hits" in client.get("/openapi.json").json()["paths"] def _report_expired(backend: MemoryBackend, key: str, entry: CacheEntry) -> None: