diff --git a/README.md b/README.md
index 224595c..43f0b65 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@
A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support and optional session management.
+**Documentation:** — guides and the full API reference.
+
## Features
### HTTP Caching
@@ -27,7 +29,7 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
- Redis
- Memcached
- In-memory cache
-- Complete Cache-Control directive implementation
+- Cache-Control directive support (see the table below)
- Easy-to-use `@cache` decorator
### Session Management (Optional Extension)
@@ -182,7 +184,9 @@ add_routes(
)
```
-- `GET {prefix}/cached-hits` — per-route hit counts and cache key information.
+- `GET {prefix}/cached-hits` — every cached entry split into method, host, path
+ 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.
@@ -243,7 +247,7 @@ entries.
**Note**: `clear()`/`clear_prefix()` are implemented via the backend's
`get_all_keys()` and `delete_many()` (one batched `DEL` on Redis). Since Memcached doesn't support key enumeration (see
-[Memcached limitations](#memcached)), these two methods are no-ops on a
+[Memcached limitations](#memcached)), these methods — and `clear_pattern()` — are no-ops on a
Memcached backend — `get()`/`set()`/`delete()`/`has()` work normally. Use
Redis or the in-memory backend if you need bulk clearing.
@@ -340,6 +344,7 @@ When a cached entry is valid (within TTL):
- **With `If-None-Match` header**: Returns HTTP 304 Not Modified if the ETag matches
- **With `no-cache` directive**: Forces revalidation with fresh content before deciding on 304
- **With `private=True`**: Nothing is read from or written to the shared backend; the handler runs every time and only `If-None-Match` revalidation applies
+- **Without `ttl`** (`ttl=None`): The cached body is never served directly; the handler runs on every request except one whose `If-None-Match` matches the stored ETag, which gets a 304
This means **cached hits are extremely fast** - the endpoint handler function is never executed.
@@ -389,8 +394,8 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300):
that holds a cached response raises `CacheXError`.
- `get_and_delete(key) -> CacheEntry | None` — Memory pops under its lock, Redis
uses `GETDEL` (server 6.2+) and Memcached returns the value only when its own
- `DELETE` won. `StateManager.consume_state`, `CacheManager.delete` and
- `invalidate()` are built on it.
+ `DELETE` won. `StateManager.consume_state`, `StateManager.delete_state`,
+ `CacheManager.delete` and `invalidate()` are built on it.
- `set_if_absent(key, value, ttl=None) -> bool` — stores `value` only when
`key` does not exist (an expired key counts as absent) and reports whether it
did. Memory checks under its lock, Redis uses `SET NX EX` and Memcached `ADD`.
@@ -410,7 +415,7 @@ real atomicity.
If you don't specify a backend, FastAPI-CacheX will use the in-memory cache by default.
This is suitable for development and testing purposes. The backend automatically runs
-a cleanup task to remove expired entries every 60 seconds.
+a cleanup task to remove expired entries every 60 seconds (`MemoryBackend(cleanup_interval=60)`).
```python
from fastapi_cachex.backends import MemoryBackend
@@ -435,7 +440,12 @@ BackendProxy.set(backend)
**Limitations**:
- Pattern-based key clearing (`clear_pattern`) is not supported by the Memcached protocol
-- Keys are namespaced with `fastapi_cachex:` prefix to avoid conflicts
+- Keys cannot be enumerated: `get_all_keys()`/`get_cache_data()` return empty
+ results (with a `RuntimeWarning`), so the monitoring routes show nothing
+- `clear_path()` deletes only the exact key given; `include_params` has no effect
+- `clear()` issues `flush_all`, which wipes the whole Memcached server, not just this namespace
+- Keys are namespaced with `fastapi_cachex:` prefix (`key_prefix=`) to avoid conflicts; a key
+ Memcached would reject (over 250 bytes, whitespace, non-ASCII) is stored under its SHA-256 digest
- Consider using Redis backend if you need pattern-based cache clearing
The synchronous pymemcache client runs in worker threads and is connection-pooled,
@@ -522,6 +532,9 @@ async def expensive_operation():
## Documentation
+The full documentation, including the API reference, is published at
+****.
+
- [Cache Flow Explanation](https://github.com/allen0099/FastAPI-CacheX/blob/master/docs/CACHE_FLOW.md)
- [Development Guide](https://github.com/allen0099/FastAPI-CacheX/blob/master/docs/DEVELOPMENT.md)
- [Known Limitations and Planned Work](https://github.com/allen0099/FastAPI-CacheX/issues)
diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md
index 931cbb9..6d6e54d 100644
--- a/docs/CACHE_FLOW.md
+++ b/docs/CACHE_FLOW.md
@@ -1,188 +1,243 @@
-# FastAPI-CacheX 快取流程說明
+# FastAPI-CacheX Cache Flow
-本文件詳細說明 FastAPI-CacheX 如何處理 HTTP 請求的快取邏輯。
+This document explains in detail how FastAPI-CacheX applies its caching logic to
+HTTP requests. All behaviour described here lives in
+[`fastapi_cachex/cache.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/cache.py)
+unless stated otherwise.
-## 整體流程圖
+## Overall flow
```
-HTTP 請求到達
+HTTP request arrives
↓
-@cache 裝飾器攔截(只有 GET 會走快取,其餘方法直接執行處理器)
+@cache decorator intercepts it (only GET goes through the cache; every other
+method runs the handler directly and gets no Cache-Control header)
↓
-生成快取金鑰: method|||host|||path|||query_params
+Build the cache key: method|||host|||path|||query_params
↓
-no-store? ── 是 → 執行處理器,不讀也不寫快取
- ↓ 否
-private? ── 是 → 執行處理器;比對 If-None-Match 決定 304 或 200
- │ (不讀也不寫共享後端)
- ↓ 否
-讀取後端條目
+no-store? ── yes → run the handler, neither read nor write the cache,
+ │ respond with Cache-Control: no-store
+ ↓ no
+private? ── yes → run the handler; compare If-None-Match to decide 304 or 200
+ │ (the shared backend is neither read nor written)
+ ↓ no
+Read the backend entry
↓
-請求帶 If-None-Match?
- ├─ 且 no-cache → 先執行處理器算出最新 ETag,相符 → 304
- ├─ 一般情況 → 與快取條目的 ETag 比對,相符 → 304
- └─ 不符 / 無此標頭 → 往下
+Request carries If-None-Match?
+ ├─ and no-cache → run the handler first to compute the current ETag; match → 304
+ ├─ otherwise → compare with the cached entry's ETag; match → 304
+ └─ no match / no header → continue
↓
-有快取條目、有 ttl、且非 no-cache
- ├─ 是 → 直接以快取內容回應(連同存下來的狀態碼與標頭;處理器 **不執行**)
- └─ 否 → 執行處理器
- ├─ 非 2xx(或 206)→ 原樣回傳,且 **不寫入**(不覆蓋既有好條目)
- ├─ 串流/檔案回應 → 無法計算 ETag,原樣回傳,不寫入
- └─ 一般回應 → 設定 ETag;與既有條目 ETag 不同才寫入後端
+Cached entry exists, ttl is set, and no-cache is off?
+ ├─ yes → respond with the cached content (including the stored status code
+ │ and headers; the handler does **not** run)
+ └─ no → run the handler
+ ├─ non-2xx (or 206) → return as-is and **do not write**
+ │ (an existing good entry is not overwritten)
+ ├─ streaming/file response → no ETag can be computed; return as-is, do not write
+ └─ regular response → set the ETag; write to the backend only if it
+ differs from the existing entry's ETag
↓
-回應附上 Cache-Control
+Attach Cache-Control to the response (non-2xx responses are returned without it)
```
-## 詳細步驟
+## Detailed steps
-### 1. 請求攔截與金鑰生成
+### 1. Request interception and key generation
-當請求到達時,`@cache` 裝飾器會:
+When a request arrives, the `@cache` decorator does the following:
```python
from fastapi_cachex.types import CACHE_KEY_SEPARATOR # "|||"
-# 快取金鑰格式(fastapi_cachex/cache.py 的 default_key_builder)
+# Cache key format (default_key_builder in fastapi_cachex/cache.py)
cache_key = CACHE_KEY_SEPARATOR.join(
[request.method, request.headers.get("host", "unknown"), request.url.path, query]
)
-# 例如:
+# For example:
# GET|||example.com|||/api/users|||page=1&limit=10
# GET|||api.example.com|||/api/users/123|||
```
-分隔符用 `|||` 而不是冒號,是因為 host 本身可能含連接埠(`127.0.0.1:8000`),
-用冒號會讓金鑰無法被正確拆解 —— `MemoryBackend.clear_path()` 需要從金鑰反解出路徑。
+The separator is `|||` rather than a colon because the host itself may contain a
+port (`127.0.0.1:8000`); with a colon the key could not be split reliably, and
+`clear_path()` needs to recover the path from the key.
-查詢參數是照請求原本的順序串接(`str(request.query_params)`),**不會排序**,
-所以 `?page=1&limit=10` 與 `?limit=10&page=1` 是兩份獨立的快取條目。需要把兩者
-視為同一份時,請自訂 `key_builder` 做正規化。
+Query parameters are joined in the order the request sent them
+(`str(request.query_params)`) and are **not sorted**, so `?page=1&limit=10` and
+`?limit=10&page=1` are two separate cache entries. If you want them treated as
+one, pass a custom `key_builder` that normalises the query string.
-金鑰格式確保不同維度的資料獨立快取:
-- **方法隔離**:GET 和 POST 不共享快取(且目前只有 GET 會進入快取流程)
-- **主機隔離**:`example.com` 和 `api.example.com` 分別快取
-- **路徑隔離**:不同端點各自快取
-- **查詢參數隔離**:同一端點不同查詢參數分別快取
+The key format keeps each dimension cached independently:
-### 2. Cache-Control 指令檢查
+- **Method isolation**: GET and POST do not share a cache (and currently only GET
+ enters the cache flow at all)
+- **Host isolation**: `example.com` and `api.example.com` are cached separately
+- **Path isolation**: each endpoint has its own entries
+- **Query parameter isolation**: different query parameters on the same endpoint
+ are cached separately
-裝飾器檢查各種快取指令:
+### 2. Cache-Control directives
+
+The decorator arguments control both the server-side behaviour and the
+`Cache-Control` header sent to clients:
```python
-# 完全跳過快取
-@cache(no_cache=True) # 強制重新驗證
-@cache(no_store=True) # 不儲存任何內容
-
-# 正常快取行為
-@cache(ttl=3600) # 快取 1 小時(同時作為 max-age 的值)
-@cache(public=True) # 允許共享快取
-@cache(private=True) # 僅私有快取,不進共享後端
-@cache(immutable=True) # 內容永不變更
+# Change how the server-side cache is used
+@cache(no_cache=True) # Always re-run the handler (revalidate); entries are still written
+@cache(no_store=True) # Never read or write the cache
+
+# Normal caching behaviour
+@cache(ttl=3600) # Cache for 1 hour (also used as the max-age value)
+@cache(public=True) # Allow shared caches
+@cache(private=True) # Private only; never touches the shared backend
+@cache(immutable=True) # Content never changes
+
+# Header-only directives (they do not change server-side behaviour)
+@cache(ttl=60, must_revalidate=True) # must-revalidate
+@cache(ttl=60, stale="revalidate", stale_ttl=30) # stale-while-revalidate=30
+@cache(ttl=60, stale="error", stale_ttl=300) # stale-if-error=300
```
+Arguments are validated when the decorator is applied, and a `CacheXError` is
+raised if `public` and `private` are both set, or if only one of `stale` /
+`stale_ttl` is given.
+
+The header value is built once per decorated route:
+
+| Arguments | `Cache-Control` sent |
+|-----------|----------------------|
+| `no_store=True` | `no-store` (overrides everything else) |
+| `no_cache=True` | `no-cache`, plus `must-revalidate` if requested; `public`/`private`/`max-age`/`stale-*`/`immutable` are omitted |
+| anything else | in order: `public` or `private`, `max-age=`, `must-revalidate`, `stale-while-revalidate=` or `stale-if-error=`, `immutable` |
+
+> [!NOTE]
+> Without `ttl`, an entry is still written (with no expiry) but is never served
+> directly: it is only used to answer a matching `If-None-Match` with `304`. Set
+> `ttl` to have the server replay cached responses.
+
> [!WARNING]
-> **預設快取金鑰不含使用者身分**,而後端是所有 worker、所有使用者共用的。
-> 直接對需要驗證的端點使用 `@cache(ttl=...)`,會把 A 使用者的回應供應給下一
-> 個請求同一路徑的 B 使用者。
+> **The default cache key does not include the user's identity**, and the backend
+> is shared by every worker and every user. Putting `@cache(ttl=...)` directly on
+> an authenticated endpoint will serve user A's response to the next user B who
+> requests the same path.
>
-> 需要依請求者而異的端點,擇一處理:
+> For endpoints whose response depends on the caller, pick one:
>
-> 1. `private=True` — 完全不讀寫共享後端。仍會輸出 `Cache-Control: private`
-> 讓使用者自己的瀏覽器快取,`If-None-Match` 也仍以即時算出的內容比對。
-> 2. 自訂含身分的 `key_builder` — 當你確實想要「每位使用者一份」的伺服器端快取。
+> 1. `private=True` — never read from or write to the shared backend. It still
+> sends `Cache-Control: private` so the user's own browser can cache the
+> response, and `If-None-Match` is still compared against freshly rendered
+> content.
+> 2. A custom `key_builder` that includes the identity — when you really do want
+> a per-user server-side cache.
>
-> 身分請取自可信來源(已驗簽的 token claim、依賴注入的使用者物件),
-> 不要直接採信未經檢查的客戶端標頭。
+> Take the identity from a trusted source (a verified token claim, a
+> dependency-injected user object); do not trust unchecked client headers.
-### 3. 快取查詢
+### 3. Cache lookup
-根據快取金鑰查詢後端。後端回傳的是 `CacheEntry`(過期條目由後端自己判斷並跳過):
+The backend is queried with the cache key. It returns a `CacheEntry` (expired
+entries are skipped by the backend itself):
```python
from fastapi_cachex.types import CacheEntry
entry = CacheEntry(
- fingerprint='W/"9f86d081..."', # ETag,內容的弱驗證器
- content=b'{"data": "response"}', # 原始回應位元組
+ fingerprint='W/"9f86d081..."', # ETag, a weak validator for the content
+ content=b'{"data": "response"}', # raw response bytes
media_type="application/json",
- status_code=200, # 重播時沿用原本的狀態碼
- headers={"Vary": "Accept-Encoding"}, # 重播時一併帶回的標頭
+ status_code=200, # replayed with the original status code
+ headers={"Vary": "Accept-Encoding"}, # headers sent back on replay
)
```
-TTL 不存在 `CacheEntry` 裡:到期時間是後端的責任(`MemoryBackend` 記在
-`CacheItem.expiry`,Redis 用 `SETEX`,Memcached 用 exptime)。
+The TTL is not stored in `CacheEntry`: expiry is the backend's responsibility
+(`MemoryBackend` keeps it in `CacheItem.expiry`, Redis uses `SET ... EX`,
+Memcached uses the exptime).
+
+If no backend has been configured with `BackendProxy.set()`, the decorator
+creates a `MemoryBackend` on the first request and registers it.
-**決策邏輯**(`cache.py` 的 wrapper,依序):
+**Decision logic** (the `cache.py` wrapper, in order):
```python
+if request.method != "GET":
+ return await handler() # no cache, no Cache-Control
+
if no_store:
- return await render() # 不讀、不寫
+ return await render() # no read, no write
if private:
- response, etag = await render() # 不讀、不寫共享後端
+ response, etag = await render() # shared backend neither read nor written
return not_modified(...) if etag_matches(client_etag, etag) else response
-entry = await backend.get(cache_key) # 過期條目在這裡就已被跳過
+entry = await backend.get(cache_key) # expired entries are already skipped here
if client_etag and no_cache:
- fresh = await render() # no-cache:一律先重算
+ fresh = await render() # no-cache: always re-render first
if etag_matches(client_etag, fresh.etag):
return not_modified(...) # 304
elif client_etag and entry and etag_matches(client_etag, entry.fingerprint):
- return not_modified(...) # 304,處理器不執行
+ return not_modified(...) # 304, handler does not run
if entry and not no_cache and ttl is not None:
- return Response( # 200,處理器不執行
+ return Response( # 200, handler does not run
content=entry.content,
status_code=entry.status_code,
media_type=entry.media_type,
headers={**(entry.headers or {}), "ETag": entry.fingerprint, ...},
)
-response, body, etag = await render() # 未命中
+response, body, etag = await render() # miss (reused if no-cache already rendered)
if not is_cacheable_status(response.status_code):
- return response # 非 2xx:原樣回傳且不寫入
+ return response # non-2xx: returned as-is, not written
if etag is None:
- return response # 串流/檔案:無法計算 ETag,不寫入
+ return response # streaming/file: no ETag, not written
if not entry or entry.fingerprint != etag:
await backend.set(cache_key, CacheEntry(...), ttl=ttl)
return response
```
> [!NOTE]
-> 「非 2xx 不寫入」是刻意的:暫時性錯誤不該把上一份好的快取洗掉,也不該之後被
-> 以 200 重播。`206 Partial Content` 同樣不快取。
+> "Non-2xx is not written" is deliberate: a transient error must not wipe out
+> the last good cached response, nor be replayed later as a 200. `206 Partial
+> Content` is not cached either, since its body only makes sense for the `Range`
+> request that produced it. Non-2xx responses are also never answered with
+> `304` and are returned without the decorator's `Cache-Control` header (only
+> `no_store=True` adds `no-store` to every response).
-### 4. ETag 生成與驗證
+### 4. ETag generation and validation
-ETag 由回應內容算出,用來驗證內容是否變更:
+The ETag is computed from the response body and used to detect whether the
+content has changed:
```python
-# 生成:MD5,並標成弱驗證器
+# Generation: MD5, marked as a weak validator
def _etag_for(body: bytes) -> str:
return f'W/"{hashlib.md5(body).hexdigest()}"'
```
-`If-None-Match` 依 RFC 9110 §8.8.3.2 以**弱比對**判斷,因此:
+`If-None-Match` is evaluated with **weak comparison** as specified by RFC 9110
+§8.8.3.2, so:
```
-If-None-Match: W/"abc" → 與 "abc" 視為相符(兩側都忽略 W/ 前綴)
-If-None-Match: "abc", W/"def" → 多值,逐一比對,任一相符即 304
-If-None-Match: * → 資源存在即相符 → 304
+If-None-Match: W/"abc" → matches "abc" (the W/ prefix is ignored on both sides)
+If-None-Match: "abc", W/"def" → multiple values, compared one by one; any match → 304
+If-None-Match: * → matches whenever the resource exists → 304
```
-回 304 時,會一併帶回 200 會帶的 `Cache-Control`/`ETag` 以及會影響快取的標頭
-(`Vary`、`Content-Location`、`Expires` 等),否則中介快取在重新驗證後會把這些
-欄位弄丟(RFC 9110 §15.4.5)。
+A 304 carries the same `Cache-Control` and `ETag` a 200 would, together with the
+cache-steering headers `Vary`, `Content-Location` and `Expires`; otherwise an
+intermediate cache would lose those fields after revalidation (RFC 9110
+§15.4.5).
-## 後端存儲格式
+## Backend storage formats
### MemoryBackend
```python
-# dict[str, CacheItem],CacheItem 包住 CacheEntry 並額外記到期時間
+# dict[str, CacheItem]; CacheItem wraps the CacheEntry and records its expiry
{
"GET|||example.com|||/api/users|||": CacheItem(
value=CacheEntry(
@@ -192,213 +247,276 @@ If-None-Match: * → 資源存在即相符 → 304
status_code=200,
headers=None,
),
- expiry=1702650600.5, # epoch 秒;None 表示永不過期
+ expiry=1702650600.5, # epoch seconds; None means never expires
),
}
-# 特點:
-# - 儲存於行程內記憶體,不跨行程共享
-# - 背景清理任務每 cleanup_interval 秒(預設 60)掃掉過期項目
-# - 清理任務在第一次 get/set/increment/get_and_delete 時延遲啟動
-# - get() 讀到過期項目時會就地刪除並回報 miss,不等清理任務
+# Characteristics:
+# - Stored in process memory, not shared between processes
+# - A background cleanup task sweeps expired items every cleanup_interval
+# seconds (default 60)
+# - The cleanup task starts lazily on the first get/set/set_if_absent/increment/
+# get_and_delete call
+# - get() deletes an expired item in place and reports a miss, without waiting
+# for the cleanup task
+# - Keys are not prefixed
```
-### 網路後端共用的序列化(`backends/codec.py`)
+### Serialization shared by the network backends ([`backends/codec.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/backends/codec.py))
-Redis 與 Memcached 共用同一份 JSON 編解碼;裝了 `orjson` 就用它,否則用標準庫 `json`:
+Redis and Memcached share the same JSON codec; it uses `orjson` when installed
+and the standard library `json` otherwise:
```json
{
"fingerprint": "W/\"abc123\"",
- "content": "<回應位元組以 latin-1 解碼後的字串>",
+ "content": "",
"media_type": "application/json",
"status_code": 200,
"headers": {"Vary": "Accept-Encoding"}
}
```
-- `content` 走的是 **latin-1 round-trip**,不是 base64:latin-1 與位元組一一對應,
- 所以任何位元組都能安全地放進 JSON 文字再原樣取回。
-- 舊版寫入、沒有 `status_code`/`headers` 欄位的條目仍可讀,會解成 `200` 且無額外標頭。
-- 解碼失敗(JSON 壞掉、欄位缺漏、型別不對)一律當成 **cache miss** 回 `None`,不丟例外。
-- `increment()` 留下的是**裸整數**(Redis/Memcached 的 INCR 家族所寫),解碼時會轉成
- fingerprint 為 `counter` 的 `CacheEntry`。
+- `content` uses a **latin-1 round-trip**, not base64: latin-1 maps one-to-one
+ onto bytes, so any byte sequence can be placed in JSON text and recovered
+ unchanged.
+- Entries written by older releases, without the `status_code`/`headers`
+ fields, remain readable and decode to `200` with no extra headers.
+- Any decode failure (broken JSON, missing fields, wrong types) is treated as a
+ **cache miss** and returns `None` instead of raising.
+- `increment()` leaves a **bare integer** behind (written by the Redis/Memcached
+ INCR family); it decodes to a `CacheEntry` whose fingerprint is `counter`.
### MemcachedBackend
```
key: "fastapi_cachex:GET|||example.com|||/api/users|||"
-value: 上述 JSON 內容
-
-# 特點:
-# - 金鑰含空白/控制字元或超過 250 bytes 時,整段金鑰改存 SHA-256 十六進位摘要
-# (`fastapi_cachex:`),否則 Memcached 會直接拒絕並讓請求變成 500
-# - TTL 超過 30 天時改送絕對 epoch 時間戳,否則會被解讀成 1970 年的時刻而立即過期
-# - 協定沒有金鑰列舉能力,所以 clear_pattern()/get_all_keys() 是 no-op,
-# 回 0/[] 並發出 RuntimeWarning;CacheManager.clear()/clear_prefix() 因此在此後端無效
-# - 同步 pymemcache client 跑在 worker thread,開啟連線池與 default_noreply=False
+value: the JSON document above
+
+# Characteristics:
+# - If the namespaced key contains whitespace, control characters or non-ASCII
+# bytes, or exceeds 250 bytes, it is stored under its SHA-256 hex digest
+# instead (`fastapi_cachex:`); otherwise Memcached would reject it and
+# the request would fail with a 500
+# - A TTL longer than 30 days is sent as an absolute epoch timestamp; otherwise
+# Memcached would read it as a moment in 1970 and expire the entry immediately
+# - The protocol cannot enumerate keys, so clear_pattern()/get_all_keys()/
+# get_cache_data() are no-ops that return 0/[]/{} and emit a RuntimeWarning;
+# CacheManager.clear()/clear_prefix() therefore do nothing on this backend
+# - clear_path() only deletes a key exactly equal to the given path, so it
+# cannot clear HTTP route entries
+# - clear() issues flush_all, which wipes the ENTIRE Memcached server (not just
+# this key prefix) and emits a RuntimeWarning
+# - The synchronous pymemcache client runs in worker threads, with connection
+# pooling and default_noreply=False
```
### AsyncRedisCacheBackend
```
key: "fastapi_cachex:GET|||example.com|||/api/users|||"
-value: 上述 JSON 內容
-
-# 特點:
-# - 以 SETEX 設定到期(ttl 為 None 時用 SET)
-# - 模式操作一律用 SCAN(COUNT=100)分頁走訪,不用 KEYS,不阻塞伺服器
-# - get_and_delete() 用 GETDEL(需要 Redis 6.2+),刪除以 DEL 分批送出
-# - increment() 走註冊過的 Lua script,計數與設定 TTL 是同一個原子操作
+value: the JSON document above
+
+# Characteristics:
+# - Expiry is set with SET ... EX (plain SET when ttl is None)
+# - Pattern operations page through keys with SCAN (COUNT=100) instead of KEYS,
+# so the server is never blocked
+# - clear() only removes keys under this backend's key prefix
+# - get_and_delete() uses GETDEL (requires Redis 6.2+); deletions are sent as
+# batched DELs of up to 100 keys
+# - increment() runs a registered Lua script, so incrementing and setting the TTL
+# are one atomic operation
```
> [!NOTE]
-> **監控端點的 TTL 欄位在 Redis 後端不可用。**
-> `AsyncRedisCacheBackend.get_cache_data()` 對每個金鑰回 `(entry, None)`,沒有去查
-> 每個金鑰的實際 TTL,所以 `add_routes()` 掛出來的 `/cached-records` 會把 Redis 條目
-> 一律顯示為「永不過期」。實際過期仍由 Redis 自己執行,只是監控看不到剩餘秒數。
+> **The TTL fields of the monitoring endpoints are unavailable on the Redis
+> backend.** `AsyncRedisCacheBackend.get_cache_data()` returns `(entry, None)`
+> for every key without querying each key's actual TTL, so the
+> `/cached-hits` and `/cached-records` routes mounted by `add_routes()` show every
+> Redis entry as never expiring (`ttl_remaining: null`). Redis still enforces
+> expiry itself; the monitoring just cannot see the remaining seconds. On
+> Memcached these endpoints return no entries at all.
-## 快取清除策略
+## Cache clearing strategies
-### 自動清除
+### Automatic clearing
```python
-# MemoryBackend: 每 cleanup_interval 秒(預設 60)掃一次
+# MemoryBackend: sweeps every cleanup_interval seconds (default 60)
async def cleanup_task():
while True:
await asyncio.sleep(self.cleanup_interval)
- # 移除所有 CacheItem.expiry 已過的項目
+ # remove every item whose CacheItem.expiry has passed
-# 這個任務在第一次 get/set/increment/get_and_delete 時才延遲啟動(需要有 event loop),
-# 所以「只寫不讀」的用法(例如 StateManager.create_state)也會把它帶起來。
+# The task is only started lazily on the first get/set/set_if_absent/increment/
+# get_and_delete call (it needs a running event loop), so write-only usage (for
+# example StateManager.create_state) starts it too.
-# Redis/Memcached: TTL 機制
-# 使用後端的內置 TTL (SETEX, exptime)
-# 項目自動過期,無需清理任務
+# Redis/Memcached: TTL mechanism
+# Use the backend's built-in TTL (SET ... EX, exptime)
+# Items expire on their own; no cleanup task is needed
```
-### 手動清除
+### Manual clearing
+
+The clearing methods live on the backend, which you can inject with the
+`CacheBackend` dependency or fetch with `BackendProxy.get()`:
```python
-# 清除特定路徑
-await cache.clear_path("/api/users") # 移除所有 host/method/params 組合
+from fastapi_cachex import CacheBackend
-# 清除模式:比對的是完整金鑰 method|||host|||path|||query
-await cache.clear_pattern("GET|||*|||/api/users/*") # 移除 /api/users/... 的 GET 項目
-await cache.clear_pattern("cache:user:*") # 自己組的金鑰(如 CacheManager)直接比對
-# 清除全部
-await cache.clear() # 移除所有快取項目
+@app.post("/admin/clear")
+async def clear(cache: CacheBackend) -> None:
+ # Clear a specific path: only entries WITHOUT query params...
+ await cache.clear_path("/api/users")
+ # ...or every query-param variant too
+ await cache.clear_path("/api/users", include_params=True)
+
+ # Clear by pattern: matched against the whole key method|||host|||path|||query
+ await cache.clear_pattern("GET|||*|||/api/users/*")
+ # Keys you built yourself (e.g. CacheManager keys) match directly
+ await cache.clear_pattern("cache:user:*")
+
+ # Clear everything
+ await cache.clear() # removes every cache entry
```
-單獨讓某條已快取路由失效(例如寫入後要打掉對應的 GET 快取),用 top-level 的
-`invalidate()`,它會用同一組 key_builder 重建金鑰再刪除:
+`clear_path()` matches entries for the path across every method and host. A
+pattern written as a bare path (for example `clear_pattern("/api/users/*")`)
+cannot match an HTTP key; when such a call clears nothing it emits a
+`RuntimeWarning` pointing you to `clear_path()`.
+
+To invalidate a single cached route (for example, dropping the matching GET
+entry after a write), use the top-level `invalidate()`. It rebuilds the key with
+the same key builder and deletes it:
```python
from fastapi_cachex import invalidate
-removed: bool = await invalidate(request) # 用預設 key_builder
-removed = await invalidate(request, key_builder=my_key_builder) # 路由有自訂時要一致
+removed: bool = await invalidate(request) # uses the default key_builder
+removed = await invalidate(
+ request, key_builder=my_key_builder
+) # must match the route's custom builder
```
-回傳值代表「原本是否存在該條目」。未設定後端時回 `False` 而不拋例外。
+The return value tells you whether the entry existed. If no backend is
+configured it returns `False` instead of raising.
-## 效能最佳化
+## Performance
-### 快取命中路徑
+### Cache-hit path
```
-請求 → 快取查詢 (< 5ms)
- ↓
- 返回快取 (< 1ms)
+Request → cache lookup (< 5 ms)
+ ↓
+ return cached response (< 1 ms)
-總耗時: ~5-10ms (無需執行端點處理器)
-相比直接執行: 節省 100-1000ms+ (取決於端點複雜度)
+Total: ~5-10 ms (the endpoint handler does not run)
+Compared with running the handler: saves 100-1000 ms+ (depending on endpoint complexity)
```
-### 後端選擇建議
+These figures are illustrative; actual numbers depend on the backend and the
+network.
-| 場景 | 推薦後端 | 原因 |
-|------|--------|------|
-| 開發測試 | MemoryBackend | 快速、無依賴 |
-| 分散式系統 | Redis | 非同步、高效、支援模式清除 |
-| 簡單快取 | Memcached | 穩定、成熟 |
-| 多行程部署 | Redis | 共享快取、一致性 |
+### Choosing a backend
-## 快取失效場景
+| Scenario | Recommended backend | Why |
+|----------|---------------------|-----|
+| Development and testing | MemoryBackend | Fast, no dependencies |
+| Distributed systems | Redis | Async, efficient, supports pattern clearing |
+| Simple caching | Memcached | Stable, mature (but no key enumeration, so no pattern/path clearing or monitoring) |
+| Multi-process deployments | Redis | Shared cache, consistency |
-| 場景 | 行為 |
-|------|------|
-| `no_store=True` | 不讀也不寫快取,每次都執行端點 |
-| `no_cache=True` | 每次都執行端點重算 ETag;與客戶端 `If-None-Match` 相符時仍回 304,並在 ETag 變動時更新快取 |
-| `private=True` | 不讀也不寫**共享後端**;仍輸出 `Cache-Control: private` 並以即時內容做 ETag 比對 |
-| 快取過期(TTL 到期) | 重新執行端點;`MemoryBackend` 讀到過期條目會就地刪除 |
-| 回應為非 2xx 或 206 | 原樣回傳,不寫入,也不覆蓋既有條目 |
-| 回應為串流/檔案 | 無法計算 ETag,原樣回傳且不寫入 |
-| 手動呼叫 `invalidate()` | 該路由對應金鑰被刪除 |
-| 手動呼叫 `clear_path()` / `clear_pattern()` / `clear()` | 依範圍清除(Memcached 後端的後兩者為 no-op) |
+## Cache invalidation scenarios
-## 實現細節
+| Scenario | Behaviour |
+|----------|-----------|
+| `no_store=True` | The cache is neither read nor written; the endpoint runs every time |
+| `no_cache=True` | The endpoint runs every time to recompute the ETag; a match with the client's `If-None-Match` still returns 304, and the cache is updated when the ETag changes |
+| `private=True` | The **shared backend** is neither read nor written; `Cache-Control: private` is still sent and the ETag is compared against fresh content |
+| No `ttl` | Entries are written without expiry but only used for `If-None-Match` revalidation; the handler runs on every request without a matching validator |
+| Cache expired (TTL elapsed) | The endpoint runs again; `MemoryBackend` deletes the expired entry in place when it reads it |
+| Non-2xx or 206 response | Returned as-is, not written, and any existing entry is left untouched |
+| Streaming/file response | No ETag can be computed; returned as-is and not written |
+| Manual `invalidate()` call | The key for that route is deleted |
+| Manual `clear_path()` / `clear_pattern()` / `clear()` call | Cleared according to scope (on Memcached, `clear_path()` only deletes an exact key, `clear_pattern()` is a no-op, and `clear()` flushes the whole server) |
-### 快取項目結構
+## Implementation details
-實際型別是 dataclass,定義在 `fastapi_cachex/types.py`:
+### Cache entry structure
+
+The actual types are dataclasses defined in
+[`fastapi_cachex/types.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/types.py):
```python
@dataclass
class CacheEntry:
- fingerprint: str # ETag,格式為 W/""
- content: bytes # 原始回應位元組
+ fingerprint: str # ETag, formatted as W/""
+ content: bytes # raw response bytes
media_type: str | None = None
- status_code: int = 200 # 重播時沿用
- headers: dict[str, str] | None = None # 重播時一併帶回
+ status_code: int = 200 # replayed as-is
+ headers: dict[str, str] | None = None # sent back on replay
@dataclass
class CacheItem:
value: CacheEntry
- expiry: float | None = None # epoch 秒;僅 MemoryBackend 使用
+ expiry: float | None = None # epoch seconds; used by MemoryBackend only
```
-`headers` 存的是處理器自己設定的標頭,但會排除每次回應都要重算或不該重播的欄位:
-`Set-Cookie`、`Content-Length`、`Transfer-Encoding`、`Connection`、`Date`、`ETag`、
-`Cache-Control`、`Content-Type`(`Content-Type` 由 `media_type` 還原,存兩份會重複輸出)。
+`headers` stores the headers the handler set itself, excluding fields that must
+be recomputed for every response or must not be replayed: `Set-Cookie`,
+`Content-Length`, `Transfer-Encoding`, `Connection`, `Date`, `ETag`,
+`Cache-Control` and `Content-Type` (`Content-Type` is restored from
+`media_type`; storing both would emit the header twice).
-計數器(`backend.increment()`)也以 `CacheEntry` 呈現:fingerprint 固定為 `counter`,
-`content` 是十進位數字的位元組,因此刪除、清除與監控都能一視同仁地處理。
+Counters (`backend.increment()`) are also represented as a `CacheEntry`: the
+fingerprint is always `counter` and `content` is the decimal value as bytes, so
+deletion, clearing and monitoring all treat them the same way.
-### 請求流程程式碼範例
+### Request flow code example
-裝飾器內部的實際順序見上面〈3. 快取查詢〉的決策邏輯;使用端只需要:
+See the decision logic in "3. Cache lookup" above for the decorator's internal
+order; on the user side all you need is:
```python
+@app.get("/expensive")
@cache(ttl=3600)
async def expensive_endpoint():
- # 此函式只在快取未命中(或需要重新驗證)時執行
+ # This function only runs on a cache miss (or when revalidation is needed)
return await perform_calculation()
```
-處理器不需要自己宣告 `Request`;`@cache` 會在簽名中注入一個名為 `__cachex_request`
-的 keyword-only 參數。若處理器**已經**宣告了 `Request`(含字串註解、`Annotated[...]`
-或 `Request` 子類),就直接沿用該參數,不會重複注入。
-
-## 常見問題
-
-**Q: 為何快取命中不返回 200?**
-A: 不一定。如果請求帶有 `If-None-Match` 標頭且 ETag 匹配,返回 304 以節省帶寬。無標頭時返回 200 和內容。
-
-**Q: 為何 POST/PUT 的回應沒有被快取?**
-A: `@cache` 只對 GET 生效,其餘方法一律直接執行處理器(仍會輸出 Cache-Control 標頭)。
-
-**Q: 為何同一端點有多個快取項目?**
-A: 因為快取金鑰包含查詢參數,而且**不會排序**。`/users?page=1` 和 `/users?page=2`
-是不同的快取;`?a=1&b=2` 與 `?b=2&a=1` 也是。
-
-**Q: MemoryBackend 如何在多行程中工作?**
-A: 不工作。每個行程有獨立快取,推薦生產環境使用 Redis。
-
-**Q: 快取清除是同步還是非同步?**
-A: 非同步操作。`await cache.clear_path(...)` 或 `await cache.clear_pattern(...)`。
-注意 `clear_pattern()`(以及 `get_all_keys()`、`CacheManager.clear()`)在 Memcached
-後端是 no-op,因為 Memcached 協定沒有金鑰列舉能力。
+The handler does not have to declare a `Request` itself: `@cache` injects a
+keyword-only parameter named `__cachex_request` into the signature (placed
+before `**kwargs` if the handler has one). If the handler **already** declares a
+`Request` (including a string annotation, `Annotated[...]`, or a `Request`
+subclass), that parameter is reused and nothing is injected.
+
+## FAQ
+
+**Q: Why doesn't a cache hit always return 200?**
+A: It depends. If the request carries an `If-None-Match` header whose ETag
+matches, a 304 is returned to save bandwidth. Without the header, a 200 with the
+content is returned.
+
+**Q: Why aren't POST/PUT responses cached?**
+A: `@cache` only applies to GET. Every other method runs the handler directly,
+without reading or writing the cache and without adding a `Cache-Control`
+header.
+
+**Q: Why are there several cache entries for the same endpoint?**
+A: Because the cache key includes the query parameters, and they are **not
+sorted**. `/users?page=1` and `/users?page=2` are different entries, and so are
+`?a=1&b=2` and `?b=2&a=1`.
+
+**Q: How does MemoryBackend work across multiple processes?**
+A: It doesn't. Each process has its own cache; use Redis in production.
+
+**Q: Is clearing the cache synchronous or asynchronous?**
+A: Asynchronous: `await cache.clear_path(...)` or `await
+cache.clear_pattern(...)`. Note that `clear_pattern()` (as well as
+`get_all_keys()` and `CacheManager.clear()`) is a no-op on the Memcached
+backend, because the Memcached protocol cannot enumerate keys.
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 46459c6..9ff0235 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -36,9 +36,13 @@ Please refer to our [Development Guide](DEVELOPMENT.md) for detailed instruction
release notes are built from, and a release refuses to run on an empty one,
so an omission surfaces — but only at release time, and only as "somebody
forgot", never as which PR it was
-3. Update the documentation with any new dependencies, features, or changes
+3. Update the documentation with any new dependencies, features, or changes.
+ New public API needs a docstring and, if it lives in a module not yet
+ covered, an entry under `docs/api/`; check the site with
+ `uv run zensical build --strict` (see
+ [Documentation site](DEVELOPMENT.md#documentation-site))
4. The PR may be merged once you have the sign-off of at least one other developer
## Any Questions?
-Feel free to open an issue with the tag "question" if you need any help!
+Feel free to open an issue with the `question` label if you need any help!
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 5973029..42e35a6 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -58,8 +58,8 @@ so do not point these at anything you care about. When a port is set but nothing
is listening, the suites skip and say so.
A run with nothing opted in still clears the coverage gate (`fail_under = 90`)
-at about 92.8%, but only because the rest of the suite carries it — `redis.py`
-alone drops to roughly 29%. The margin is thin, so the first place an untested
+at about 92.2%, but only because the rest of the suite carries it — `redis.py`
+alone drops to roughly 27%. The margin is thin, so the first place an untested
line shows up as a failure is a local opted-out run, not CI, which sets both
variables against its own service containers and sees 99.95%.
@@ -108,9 +108,13 @@ Worth doing whenever a test is written for something security-relevant.
## Using tox
-tox ensures the code works across different Python versions (3.10-3.13).
+tox ensures the code works across different Python versions (3.10-3.14, the
+`env_list` in `tox.ini`). Each environment installs the `redis` and `memcache`
+extras through `tox-uv` and passes the `CACHEX_TEST_*` and
+`CACHEX_REQUIRE_LIVE_SERVERS` variables through, so the opt-in rules above apply
+unchanged.
-1. Install all Python versions
+1. Install all Python versions (`uv python install 3.10 3.11 3.12 3.13 3.14`)
2. Run tox:
```bash
@@ -120,19 +124,19 @@ uv run tox
To run for a specific Python version:
```bash
-tox -e py310 # only run for Python 3.10
+uv run tox -e py310 # only run for Python 3.10
```
## Using pre-commit
pre-commit helps maintain code quality by running checks before each commit.
-1. Install pre-commit:
-```bash
-uv add --dev pre-commit
-```
+1. pre-commit is part of the `dev` dependency group, so `uv sync --group dev`
+ already installs it.
-2. Install the pre-commit hooks:
+2. Install the git hooks (`.pre-commit-config.yaml` sets
+ `default_install_hook_types`, so this installs the `pre-commit`,
+ `post-commit` and `post-merge` hooks together):
```bash
uv run pre-commit install
```
@@ -142,7 +146,20 @@ uv run pre-commit install
uv run pre-commit run --all-files
```
-The pre-commit hooks will automatically run on `git commit`. If any checks fail, fix the issues and try committing again.
+The hooks cover the standard pre-commit-hooks checks, `ruff` (with `--fix`) and
+`ruff-format`, `typos`, `uv-lock`/`uv-sync`, and strict `mypy` (excluding
+`docs/` and `scripts/`). They run automatically on `git commit`. If any checks fail, fix the issues and try committing again.
+
+pre-commit only sees the files you touched. The **Lint** workflow checks the
+whole tree; run the same commands before pushing:
+
+```bash
+uv run ruff check fastapi_cachex tests scripts
+uv run ruff format --check fastapi_cachex tests scripts
+uv run mypy fastapi_cachex --strict
+uv run mypy tests
+uv run mypy scripts
+```
## Type Checking with mypy
@@ -153,7 +170,7 @@ We use mypy for static type checking to ensure type safety.
uv run mypy fastapi_cachex
```
-2. Run mypy with strict mode:
+2. Run mypy with strict mode (what CI and the release gate run):
```bash
uv run mypy fastapi_cachex --strict
```
@@ -161,9 +178,45 @@ uv run mypy fastapi_cachex --strict
### Common mypy Issues
- Make sure all functions have type annotations
-- Use `Optional[Type]` for parameters that could be None
+- Use `Type | None` for parameters that could be None (the codebase uses PEP 604 unions, not `Optional`)
- Use `from __future__ import annotations` for forward references
-- Add `py.typed` file to make your package mypy compliant
+- Keep `fastapi_cachex/py.typed` in place; it is what makes the installed package typed for users
+ (it is listed under `[tool.uv.build-backend] include` in `pyproject.toml`)
+
+## Documentation site
+
+The documentation at is built
+with [Zensical](https://zensical.org/) from `zensical.toml` and the `docs/`
+directory. The home page includes `README.md` and the changelog page includes
+`CHANGELOG.md` through snippets, so links in `README.md` must stay absolute
+(`https://github.com/allen0099/FastAPI-CacheX/blob/master/...`) to work in both
+places.
+
+```bash
+uv sync --group docs
+uv run zensical serve # live preview on http://localhost:8000
+uv run zensical build --strict # what CI and Read the Docs run
+```
+
+The **Docs** workflow (`.github/workflows/docs.yml`) and Read the Docs
+(`.readthedocs.yaml`) both run `zensical build --strict`, so a broken link,
+snippet path or docstring reference fails the PR.
+
+### Adding API reference pages
+
+API pages live under `docs/api/` and are generated from docstrings (Google
+style) by mkdocstrings. A page is plain markdown with one directive per object:
+
+```markdown
+# CacheManager
+
+::: fastapi_cachex.manager.CacheManager
+```
+
+Use the full module path where the object is defined (a bare module path such as
+`::: fastapi_cachex.types` documents the whole module), and add a new page to the
+`nav` in `zensical.toml`. mkdocstrings reads the package statically, so the docs
+build does not need the package or its extras installed.
## Releasing
diff --git a/docs/JWT_CLAIMS.md b/docs/JWT_CLAIMS.md
index e5bb2be..74f83d8 100644
--- a/docs/JWT_CLAIMS.md
+++ b/docs/JWT_CLAIMS.md
@@ -1,157 +1,183 @@
-# JWT Claims 實作說明與擴展指南
+# JWT Claims: Implementation Notes and Extension Guide
-## 概述
+## Overview
-FastAPI-CacheX 的 JWT token serializer 實作了基本的 JWT claims 以支援安全的 session token 傳輸。本文件說明:
+FastAPI-CacheX's JWT token serializer implements a minimal set of JWT claims to carry session tokens securely. This document explains:
-1. 為什麼我們沒有實作完整的 JWT claims(如 `jti`、`nbf`)
-2. 當前實作的設計考量
-3. 如何擴展以新增自訂 claims
+1. Why we do not implement the full set of JWT claims (such as `jti` and `nbf`)
+2. The design considerations behind the current implementation
+3. How to extend the serializer with custom claims
-## 當前實作的 JWT Claims
+The implementation lives in [`fastapi_cachex/session/token_serializers.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/fastapi_cachex/session/token_serializers.py). JWT support requires the optional extra: `pip install "fastapi-cachex[jwt]"`.
-### 已實作的標準 Claims
+## JWT Claims in the Current Implementation
-`JWTTokenSerializer` 實作了以下 JWT claims:
+### Implemented Standard Claims
-| Claim | 名稱 | 必需 | 驗證 | 說明 |
-|-------|------|------|------|------|
-| `sid` | Session ID | ✅ | ✅ | 自訂 claim,用於對應伺服器端 session |
-| `iat` | Issued At | ✅ | ✅ | Token 簽發時間(RFC 7519) |
-| `exp` | Expiration | ✅ | ✅ | Token 過期時間(`iat + session_ttl`) |
-| `iss` | Issuer | ⚠️ | ✅ | Token 簽發者(可選,需配置) |
-| `aud` | Audience | ⚠️ | ✅ | Token 目標受眾(可選,需配置) |
+`JWTTokenSerializer` implements the following JWT claims:
-### 未實作的標準 Claims
+| Claim | Name | Required | Verified | Description |
+|-------|------|----------|----------|-------------|
+| `sid` | Session ID | ✅ | ✅ | Custom claim that maps to the server-side session |
+| `iat` | Issued At | ✅ | ✅ | Time the token was issued (RFC 7519); rejected if it lies in the future (beyond `jwt_leeway`) |
+| `exp` | Expiration | ✅ | ✅ | Token expiry: the session's `expires_at` (so it follows sliding expiration), falling back to `iat + session_ttl` |
+| `iss` | Issuer | ⚠️ | ✅ | Token issuer (optional; only issued and verified when `jwt_issuer` is set) |
+| `aud` | Audience | ⚠️ | ✅ | Intended audience (optional; only issued and verified when `jwt_audience` is set) |
-以下是 RFC 7519 定義但**未實作**的可選 claims:
+### Related `SessionConfig` Fields
-| Claim | 名稱 | 用途 | 為何未實作 |
-|-------|------|------|-----------|
-| `jti` | JWT ID | Token 唯一識別碼,防止重放攻擊 | Stateful session 模型已透過伺服器端狀態處理 |
-| `nbf` | Not Before | Token 生效時間 | Session 通常立即生效,不需要延遲生效 |
-| `sub` | Subject | 主體識別碼(通常是使用者 ID) | 使用自訂 `sid` claim 表示 session ID 更清晰 |
+| Field | Default | Description |
+|-------|---------|-------------|
+| `token_format` | `"simple"` | Set to `"jwt"` to use `JWTTokenSerializer` |
+| `secret_key` | (required) | Signing key, at least 32 characters; used both to sign and to verify the JWT |
+| `jwt_algorithm` | `"HS256"` | Signing algorithm; must be one of the supported values (`none` is rejected) |
+| `jwt_issuer` | `None` | Expected `iss`; issued and verified when set |
+| `jwt_audience` | `None` | Expected `aud`; issued and verified when set |
+| `jwt_leeway` | `0` | Leeway in seconds for `exp`/`iat` validation |
+| `session_ttl` | `3600` | Session lifetime in seconds; used for `exp` when the session has no `expires_at` |
-## 設計理念
+> [!NOTE]
+> **Asymmetric algorithms:** `jwt_algorithm` accepts `HS*`, `RS*`, `ES*`, `PS*` and `EdDSA`, but the built-in serializer signs and verifies with the single `secret_key` string. In practice only the HMAC algorithms (`HS256`, `HS384`, `HS512`) work out of the box; an asymmetric algorithm needs a custom serializer that encodes with a private key and decodes with the matching public key (see [Extension Guide](#extension-guide-adding-custom-claims)).
+
+### Standard Claims That Are Not Implemented
+
+The following optional claims defined by RFC 7519 are **not implemented**:
+
+| Claim | Name | Purpose | Why it is not implemented |
+|-------|------|---------|---------------------------|
+| `jti` | JWT ID | Unique token identifier, prevents replay attacks | The stateful session model already handles this through server-side state |
+| `nbf` | Not Before | Time the token becomes valid | Sessions normally take effect immediately; no delayed activation is needed |
+| `sub` | Subject | Subject identifier (usually the user ID) | A custom `sid` claim is clearer for representing a session ID |
+
+## Design Rationale
### Stateful Session vs Stateless JWT
-FastAPI-CacheX 採用 **stateful session** 模型,這與純 stateless JWT 有根本性差異:
+FastAPI-CacheX uses a **stateful session** model, which is fundamentally different from a purely stateless JWT:
```
┌─────────────────────────────────────────────────────────┐
│ FastAPI-CacheX Session Model (Stateful) │
├─────────────────────────────────────────────────────────┤
-│ │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
-│ │ Client │ JWT │ Server │ │ Redis/ │ │
-│ │ │ ──────> │ │ ────> │ Cache │ │
-│ │ │ (sid) │ │ lookup │ │ │
-│ └──────────┘ └──────────┘ └──────────┘ │
-│ │
-│ JWT 只攜帶 session ID (sid) │
-│ 實際 session 資料儲存在伺服器端 │
-│ 可立即撤銷(刪除 cache 中的 session) │
+│ │
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
+│ │ Client │ JWT │ Server │ │ Redis/ │ │
+│ │ │ ──────> │ │ ────> │ Cache │ │
+│ │ │ (sid) │ │ lookup │ │ │
+│ └──────────┘ └──────────┘ └──────────┘ │
+│ │
+│ The JWT carries only the session ID (sid) │
+│ The actual session data is stored server-side │
+│ Revocable instantly (delete the session from cache) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Traditional Stateless JWT (NOT used by CacheX) │
├─────────────────────────────────────────────────────────┤
-│ │
+│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Client │ JWT │ Server │ │
│ │ │ ──────> │ │ │
│ │ │ (all) │ │ │
│ └──────────┘ └──────────┘ │
-│ │
-│ JWT 包含所有使用者資訊和權限 │
-│ 伺服器無狀態,無法撤銷 token │
-│ 需要 jti + blacklist 才能撤銷 │
+│ │
+│ The JWT contains all user info and permissions │
+│ The server is stateless and cannot revoke tokens │
+│ Revocation requires jti + a blacklist │
└─────────────────────────────────────────────────────────┘
```
-### 為何選擇 Stateful Session
+A valid JWT signature is necessary but not sufficient: after decoding, `SessionManager.get_session()` still loads the session from the backend and rejects it if it is missing, not active, expired, past `absolute_timeout`, or fails IP/User-Agent binding checks. Any decoding failure is raised as `SessionTokenError`, which `SessionMiddleware` treats as "no session".
+
+### Why a Stateful Session
-#### ✅ 優點
+#### ✅ Advantages
-1. **即時撤銷**
- - 透過 `SessionManager.delete_session()` 立即失效
- - 不需要維護 token 黑名單
- - 不需要 `jti` claim 和 blacklist 系統
+1. **Instant revocation**
+ - `SessionManager.delete_session()` (or `invalidate_session()`) takes effect immediately
+ - No token blacklist to maintain
+ - No need for a `jti` claim and a blacklist system
-2. **敏感資料保護**
- - Session 資料(包含 user info)儲存在伺服器端
- - JWT 只包含最小資訊(session ID)
- - 降低 JWT 洩漏的風險
+2. **Protection of sensitive data**
+ - Session data (including user info) is stored server-side
+ - The JWT contains only minimal information (the session ID)
+ - Reduces the impact of a leaked JWT
-3. **靈活的 Session 管理**
- - 支援 sliding expiration(滑動過期)
- - 支援 session 資料即時更新
- - 支援 flash messages 等功能
+3. **Flexible session management**
+ - Supports sliding expiration: when a session is renewed, a new token with an updated `exp` is returned in the response header named by `header_name` (`X-Session-Token` by default)
+ - Supports updating session data in real time
+ - Supports features such as flash messages
-4. **Token 體積小**
- - JWT 只需攜帶 `sid` 和時間戳記
- - 減少網路傳輸開銷
- - 適合 API-first 架構的頻繁請求
+4. **Small tokens**
+ - The JWT only needs to carry `sid` and timestamps
+ - Less network overhead
+ - Well suited to the frequent requests of API-first architectures
-#### ⚠️ 權衡
+#### ⚠️ Trade-offs
-1. **需要後端儲存**
- - 需要 Redis/Memcached/Memory backend
- - 橫向擴展需要共享 cache(如 Redis cluster)
+1. **Requires backend storage**
+ - Needs a Redis, Memcached, or Memory backend
+ - Horizontal scaling requires a shared cache (such as a Redis cluster)
-2. **每次請求需查詢 cache**
- - 增加一次 cache lookup
- - 但現代 cache 系統(Redis)非常快速(sub-millisecond)
+2. **Every request needs a cache lookup**
+ - Adds one cache lookup per request
+ - But modern cache systems (Redis) are very fast (sub-millisecond)
-### 為何不需要某些 Claims
+### Why Some Claims Are Not Needed
#### `jti` (JWT ID)
-**用途**:為每個 JWT 生成唯一 ID,用於:
-- Token 黑名單(blacklist)
-- 防止 token 重放攻擊
-- 追蹤個別 token
+**Purpose**: generate a unique ID for every JWT, used for:
+
+- Token blacklists
+- Preventing token replay attacks
+- Tracking individual tokens
+
+**Why it is not needed**:
-**為何不需要**:
```python
-# Stateless JWT 需要 jti + blacklist
+# A stateless JWT needs jti + a blacklist
jwt_payload = {"jti": "uuid-1234", "user_id": "123", ...}
-# 撤銷時:將 jti 加入 blacklist,每次驗證時檢查
+# To revoke: add the jti to a blacklist and check it on every verification
# FastAPI-CacheX stateful session
jwt_payload = {"sid": "session-abc123"}
-# 撤銷時:直接刪除 cache 中的 session
+# To revoke: delete the session from the cache directly
await session_manager.delete_session("session-abc123")
-# 下次請求時,cache lookup 失敗,自動拒絕
+# On the next request the cache lookup fails and the request is rejected automatically
```
#### `nbf` (Not Before)
-**用途**:指定 token 生效時間,用於:
-- 預先簽發未來使用的 token
-- 時間同步問題的容忍
+**Purpose**: specify when a token becomes valid, used for:
+
+- Issuing tokens in advance for future use
+- Tolerating clock skew
-**為何不需要**:
-- Session 通常在建立時立即生效
-- 如需延遲生效,應在應用邏輯層處理
-- `leeway` 參數已處理時間同步問題
+**Why it is not needed**:
+
+- A session normally takes effect as soon as it is created
+- If delayed activation is required, it belongs in the application logic
+- The `jwt_leeway` setting already handles clock skew for `exp` and `iat`
#### `sub` (Subject)
-**用途**:識別 token 的主體(通常是使用者 ID)
+**Purpose**: identify the subject of the token (usually the user ID)
+
+**Why `sid` is used instead**:
-**為何使用 `sid` 取代**:
-- `sub` 通常表示**不可變**的使用者識別碼
-- `sid` 表示**可變**的 session 識別碼
-- Session regeneration 時 `sid` 會改變,但 `user_id` 不變
-- 使用 `sid` 語意更清晰
+- `sub` usually denotes an **immutable** user identifier
+- `sid` denotes a **mutable** session identifier
+- `SessionManager.regenerate_session_id()` changes `sid`, while `user_id` stays the same
+- `sid` makes the semantics clearer
-## 擴展指南:新增自訂 Claims
+## Extension Guide: Adding Custom Claims
-如果您的應用需要額外的 JWT claims,可以透過繼承 `JWTTokenSerializer` 來實作。
+If your application needs additional JWT claims, subclass `JWTTokenSerializer` and pass an instance to `SessionManager` through its `token_serializer` argument. Any object with `to_string(token) -> str` and `from_string(token_str) -> SessionToken` methods (the `TokenSerializer` protocol) will do; `from_string()` should raise `ValueError` for invalid tokens, which `SessionManager` converts into `SessionTokenError`.
-### 範例 1:新增 `jti` 和 `nbf`
+The examples below honour `token.expires_at` in `to_string()` the same way the built-in serializer does, so that `exp` keeps following sliding expiration.
+
+### Example 1: Adding `jti` and `nbf`
```python
from __future__ import annotations
@@ -159,23 +185,26 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
-from fastapi_cachex.session.token_serializers import JWTTokenSerializer
from fastapi_cachex.session.models import SessionToken
+from fastapi_cachex.session.token_serializers import JWTTokenSerializer
class ExtendedJWTSerializer(JWTTokenSerializer):
- """擴展 JWT serializer,新增 jti 和 nbf claims。"""
+ """Extended JWT serializer that adds the jti and nbf claims."""
def to_string(self, token: SessionToken) -> str:
- """編碼 SessionToken 為 JWT,包含 jti 和 nbf。"""
+ """Encode a SessionToken as a JWT, including jti and nbf."""
iat = int(token.issued_at.timestamp())
- exp = iat + int(self._session_ttl)
+ if token.expires_at is not None:
+ exp = int(token.expires_at.timestamp())
+ else:
+ exp = iat + int(self._session_ttl)
payload: dict[str, object] = {
"sid": token.session_id,
"iat": iat,
"exp": exp,
- "jti": str(uuid.uuid4()), # 唯一 token ID
+ "jti": str(uuid.uuid4()), # Unique token ID
"nbf": iat, # Not before = issued at
}
@@ -190,13 +219,13 @@ class ExtendedJWTSerializer(JWTTokenSerializer):
return str(encoded)
def from_string(self, token_str: str) -> SessionToken:
- """解碼並驗證 JWT,包含 jti 和 nbf 驗證。"""
+ """Decode and verify a JWT, including jti and nbf validation."""
options = {
- "require": ["sid", "iat", "exp", "jti"], # 要求 jti
+ "require": ["sid", "iat", "exp", "jti"], # Require jti
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
- "verify_nbf": True, # 驗證 nbf
+ "verify_nbf": True, # Verify nbf
}
kwargs: dict[str, object] = {
@@ -217,49 +246,58 @@ class ExtendedJWTSerializer(JWTTokenSerializer):
msg = "Invalid JWT token"
raise ValueError(msg) from e
- # 提取標準欄位
+ # Extract the standard fields
sid = str(payload["sid"])
iat = int(payload["iat"])
issued_at = datetime.fromtimestamp(iat, tz=timezone.utc)
- # 可選:記錄 jti 用於審計
+ # Optional: record the jti for auditing
jti = payload.get("jti")
- # logger.info(f"JWT decoded: sid={sid}, jti={jti}")
+ # logger.info("JWT decoded: sid=%s, jti=%s", sid, jti)
return SessionToken(session_id=sid, signature="", issued_at=issued_at)
```
-### 範例 2:新增多租戶自訂 Claims
+### Example 2: Adding Multi-Tenant Custom Claims
```python
from __future__ import annotations
from datetime import datetime, timezone
+from typing import Any
-from fastapi_cachex.session.token_serializers import JWTTokenSerializer
+from fastapi_cachex.session import SessionConfig
from fastapi_cachex.session.models import SessionToken
+from fastapi_cachex.session.token_serializers import JWTTokenSerializer
class MultiTenantJWTSerializer(JWTTokenSerializer):
- """多租戶 JWT serializer,新增 tenant_id 和 api_version。"""
+ """Multi-tenant JWT serializer that adds tenant_id and api_version."""
def __init__(
- self, config, tenant_id: str, api_version: str = "v1", jwt_module=None
- ):
+ self,
+ config: SessionConfig,
+ tenant_id: str,
+ api_version: str = "v1",
+ jwt_module: Any | None = None,
+ ) -> None:
super().__init__(config, jwt_module)
self.tenant_id = tenant_id
self.api_version = api_version
def to_string(self, token: SessionToken) -> str:
- """編碼 SessionToken 為 JWT,包含租戶資訊。"""
+ """Encode a SessionToken as a JWT, including tenant information."""
iat = int(token.issued_at.timestamp())
- exp = iat + int(self._session_ttl)
+ if token.expires_at is not None:
+ exp = int(token.expires_at.timestamp())
+ else:
+ exp = iat + int(self._session_ttl)
payload: dict[str, object] = {
"sid": token.session_id,
"iat": iat,
"exp": exp,
- # 自訂 claims
+ # Custom claims
"tenant_id": self.tenant_id,
"api_version": self.api_version,
}
@@ -275,7 +313,7 @@ class MultiTenantJWTSerializer(JWTTokenSerializer):
return str(encoded)
def from_string(self, token_str: str) -> SessionToken:
- """解碼並驗證 JWT,驗證租戶資訊。"""
+ """Decode and verify a JWT, validating the tenant information."""
options = {
"require": ["sid", "iat", "exp", "tenant_id", "api_version"],
"verify_signature": True,
@@ -301,7 +339,7 @@ class MultiTenantJWTSerializer(JWTTokenSerializer):
msg = "Invalid JWT token"
raise ValueError(msg) from e
- # 驗證租戶資訊
+ # Validate the tenant information
if payload["tenant_id"] != self.tenant_id:
msg = f"Invalid tenant_id: expected {self.tenant_id}, got {payload['tenant_id']}"
raise ValueError(msg)
@@ -310,7 +348,7 @@ class MultiTenantJWTSerializer(JWTTokenSerializer):
msg = f"Unsupported API version: {payload['api_version']}"
raise ValueError(msg)
- # 提取標準欄位
+ # Extract the standard fields
sid = str(payload["sid"])
iat = int(payload["iat"])
issued_at = datetime.fromtimestamp(iat, tz=timezone.utc)
@@ -318,38 +356,39 @@ class MultiTenantJWTSerializer(JWTTokenSerializer):
return SessionToken(session_id=sid, signature="", issued_at=issued_at)
```
-### 使用自訂 Serializer
+### Using a Custom Serializer
-#### 方法 1:透過 SessionManager 初始化參數(推薦)
+#### Option 1: Pass it to `SessionManager` (recommended)
```python
from fastapi import FastAPI
+
from fastapi_cachex.backends import AsyncRedisCacheBackend
-from fastapi_cachex.session import SessionManager, SessionConfig, SessionMiddleware
+from fastapi_cachex.session import SessionConfig, SessionManager, SessionMiddleware
app = FastAPI()
-# 設定 backend 和 config
+# Set up the backend and config
backend = AsyncRedisCacheBackend(host="localhost", port=6379)
config = SessionConfig(
- secret_key="your-secret-key-min-32-chars",
+ secret_key="your-secret-key-at-least-32-characters",
token_format="jwt",
jwt_algorithm="HS256",
jwt_issuer="your-company",
jwt_audience="your-api",
)
-# 建立自訂 serializer
+# Create the custom serializer
custom_serializer = MultiTenantJWTSerializer(
config=config,
tenant_id="acme-corp",
api_version="v2",
)
-# 初始化 SessionManager
-manager = SessionManager(backend, config, custom_serializer)
+# Initialize the SessionManager
+manager = SessionManager(backend, config, token_serializer=custom_serializer)
-# 新增 middleware
+# Add the middleware
app.add_middleware(
SessionMiddleware,
session_manager=manager,
@@ -357,50 +396,58 @@ app.add_middleware(
)
```
-#### 方法 2:繼承 SessionManager(進階)
+When `token_serializer` is given it overrides the built-in choice made from `token_format`. Keep `token_format="jwt"` anyway: with `"simple"`, `SessionManager` additionally performs its own HMAC signature check on the parsed token, which a JWT-based serializer does not provide.
+
+#### Option 2: Subclass `SessionManager` (advanced)
```python
-from fastapi_cachex.session import SessionManager
+from fastapi_cachex.backends.base import BaseCacheBackend
+from fastapi_cachex.session import SessionConfig, SessionManager
class MultiTenantSessionManager(SessionManager):
- """支援多租戶的 SessionManager。"""
-
- def __init__(self, backend, config, tenant_id: str):
- super().__init__(backend, config)
+ """SessionManager with multi-tenant support."""
- # 替換 token serializer
- if config.token_format == "jwt":
- self._token_serializer = MultiTenantJWTSerializer(
- config=config,
- tenant_id=tenant_id,
- )
+ def __init__(
+ self, backend: BaseCacheBackend, config: SessionConfig, tenant_id: str
+ ) -> None:
+ super().__init__(
+ backend,
+ config,
+ token_serializer=MultiTenantJWTSerializer(
+ config=config, tenant_id=tenant_id
+ ),
+ )
-# 使用
+# Usage
manager = MultiTenantSessionManager(backend, config, tenant_id="acme-corp")
```
-## 完整應用範例
+Do not replace the serializer by assigning a private attribute after construction; pass it through the `token_serializer` argument so the manager uses it for both issuing and parsing tokens.
+
+## Complete Application Example
```python
from __future__ import annotations
-from fastapi import FastAPI, Depends, HTTPException
+from fastapi import Depends, FastAPI, HTTPException
+
from fastapi_cachex.backends import AsyncRedisCacheBackend
from fastapi_cachex.session import (
+ Session,
+ SessionConfig,
SessionManager,
SessionMiddleware,
- SessionConfig,
SessionUser,
get_session,
)
-# 使用上面定義的 MultiTenantJWTSerializer
+# Uses the MultiTenantJWTSerializer defined above
app = FastAPI()
-# 初始化
+# Initialization
backend = AsyncRedisCacheBackend(host="localhost", port=6379)
config = SessionConfig(
secret_key="your-secret-key-min-32-chars-long!!",
@@ -410,15 +457,14 @@ config = SessionConfig(
jwt_audience="acme-api",
)
-# 建立自訂 serializer
+# Create the custom serializer
serializer = MultiTenantJWTSerializer(
config=config,
tenant_id="acme-corp",
api_version="v2",
)
-manager = SessionManager(backend, config)
-manager._token_serializer = serializer
+manager = SessionManager(backend, config, token_serializer=serializer)
app.add_middleware(
SessionMiddleware,
@@ -428,60 +474,66 @@ app.add_middleware(
@app.post("/auth/login")
-async def login(username: str, password: str):
- """登入端點,返回包含 tenant_id 的 JWT。"""
- # 驗證使用者(省略)
+async def login(username: str, password: str) -> dict[str, str]:
+ """Login endpoint that returns a JWT containing tenant_id."""
+ # Authenticate the user (omitted)
if username != "admin":
raise HTTPException(status_code=401, detail="Invalid credentials")
user = SessionUser(user_id="123", username=username)
session, token = await manager.create_session(user=user)
- # Token 現在包含 tenant_id 和 api_version claims
+ # The token now contains the tenant_id and api_version claims
return {
"token": token,
"token_type": "bearer",
- "tenant_id": "acme-corp", # 也可以從 config 讀取
+ "tenant_id": "acme-corp", # Could also be read from configuration
}
@app.get("/api/profile")
-async def get_profile(session=Depends(get_session)):
- """受保護的端點,自動驗證 tenant_id。"""
- # JWT 已在解碼時驗證 tenant_id 和 api_version
+async def get_profile(session: Session = Depends(get_session)) -> dict[str, str | None]:
+ """Protected endpoint; tenant_id is validated automatically."""
+ # tenant_id and api_version were already validated while decoding the JWT.
+ # A token for another tenant fails to decode, so the middleware sets no
+ # session and get_session responds with 401.
+ assert session.user is not None
return {
"user_id": session.user.user_id,
"username": session.user.username,
}
```
-## 安全考量
+## Security Considerations
+
+### 1. Token Size
-### 1. Token 大小
+Adding more claims increases the size of the JWT, which affects:
-新增更多 claims 會增加 JWT 大小,影響:
-- 網路傳輸開銷
-- Cookie 大小限制(如果使用 cookie)
-- 效能
+- Network overhead
+- Cookie size limits (if the token is stored in a cookie, e.g. with `FastAPICacheXSessionMiddleware`)
+- Performance
-**建議**:只新增必要的 claims,避免在 JWT 中包含大量資料。
+**Recommendation**: add only the claims you need and avoid putting large amounts of data in the JWT.
-### 2. 敏感資料
+### 2. Sensitive Data
-不要在 JWT 中儲存敏感資料(如密碼、信用卡號):
-- JWT 可以被解碼(base64)
-- 即使有簽名,內容仍可見
-- 使用 server-side session 儲存敏感資料
+Do not store sensitive data (such as passwords or credit card numbers) in a JWT:
-### 3. Claims 驗證
+- A JWT can be decoded (it is base64url-encoded)
+- Even with a signature, the contents are readable
+- Store sensitive data in the server-side session instead
+
+### 3. Claim Validation
+
+Always validate custom claims in `from_string()`:
-自訂 claims 務必在 `from_string()` 中驗證:
```python
-# ❌ 不好:沒有驗證
+# ❌ Bad: no validation
payload = self.jwt_encoder.decode(token_str, **kwargs)
-tenant_id = payload.get("tenant_id") # 可能不存在或無效
+tenant_id = payload.get("tenant_id") # May be missing or invalid
-# ✅ 好:嚴格驗證
+# ✅ Good: strict validation
options = {"require": ["sid", "iat", "exp", "tenant_id"]}
payload = self.jwt_encoder.decode(token_str, **kwargs)
if payload["tenant_id"] != self.expected_tenant_id:
@@ -490,16 +542,18 @@ if payload["tenant_id"] != self.expected_tenant_id:
### 4. Key Rotation
-如需支援金鑰輪替(key rotation),可使用 `kid` (Key ID) claim:
+To support key rotation, you can use the `kid` (Key ID) header parameter. The following is a sketch; `payload`, `kwargs` and `_get_key_by_id()` are yours to fill in:
```python
class KeyRotationJWTSerializer(JWTTokenSerializer):
- def __init__(self, config, key_id: str, jwt_module=None):
+ def __init__(
+ self, config: SessionConfig, key_id: str, jwt_module: Any | None = None
+ ) -> None:
super().__init__(config, jwt_module)
self.key_id = key_id
def to_string(self, token: SessionToken) -> str:
- # 新增 kid 到 JWT header
+ # Add kid to the JWT header
encoded = self.jwt_encoder.encode(
payload,
self._secret,
@@ -509,30 +563,32 @@ class KeyRotationJWTSerializer(JWTTokenSerializer):
return str(encoded)
def from_string(self, token_str: str) -> SessionToken:
- # 解析 header 以獲取 kid
+ # Parse the header to obtain kid
header = self.jwt_encoder.get_unverified_header(token_str)
kid = header.get("kid")
- # 根據 kid 選擇對應的 key
+ # Pick the matching key based on kid
key = self._get_key_by_id(kid)
payload = self.jwt_encoder.decode(token_str, key=key, **kwargs)
# ...
```
-## 測試建議
+## Testing Recommendations
-為自訂 serializer 新增測試:
+Add tests for your custom serializer:
```python
import pytest
+
from fastapi_cachex.backends.memory import MemoryBackend
-from fastapi_cachex.session import SessionManager, SessionConfig, SessionUser
+from fastapi_cachex.session import SessionConfig, SessionManager, SessionUser
+from fastapi_cachex.session.exceptions import SessionTokenError
@pytest.mark.asyncio
async def test_custom_claims_included():
- """測試自訂 claims 是否包含在 JWT 中。"""
+ """Custom claims are included in the JWT and the token round-trips."""
backend = MemoryBackend()
config = SessionConfig(secret_key="a" * 32, token_format="jwt")
@@ -541,99 +597,112 @@ async def test_custom_claims_included():
tenant_id="test-tenant",
api_version="v1",
)
-
- manager = SessionManager(backend, config)
- manager._token_serializer = serializer
+ manager = SessionManager(backend, config, token_serializer=serializer)
user = SessionUser(user_id="u1", username="alice")
session, token = await manager.create_session(user=user)
- # 驗證 token 可以被解碼
- retrieved = await manager.get_session(token)
+ # The token can be decoded and carries the custom claim
+ assert (
+ serializer.jwt_encoder.decode(token, options={"verify_signature": False})[
+ "tenant_id"
+ ]
+ == "test-tenant"
+ )
+
+ # get_session returns (session, renewed_token)
+ retrieved, _renewed = await manager.get_session(token)
assert retrieved.session_id == session.session_id
@pytest.mark.asyncio
async def test_custom_claims_validated():
- """測試自訂 claims 驗證失敗時被拒絕。"""
+ """A token whose custom claims fail validation is rejected."""
backend = MemoryBackend()
config = SessionConfig(secret_key="a" * 32, token_format="jwt")
- # 建立 token with tenant_id="tenant-1"
- serializer1 = MultiTenantJWTSerializer(config, tenant_id="tenant-1")
- manager1 = SessionManager(backend, config)
- manager1._token_serializer = serializer1
+ # Create a token with tenant_id="tenant-1"
+ manager1 = SessionManager(
+ backend,
+ config,
+ token_serializer=MultiTenantJWTSerializer(config, tenant_id="tenant-1"),
+ )
_session, token = await manager1.create_session(user=SessionUser(user_id="u1"))
- # 嘗試用 tenant_id="tenant-2" 驗證(應失敗)
- serializer2 = MultiTenantJWTSerializer(config, tenant_id="tenant-2")
- manager2 = SessionManager(backend, config)
- manager2._token_serializer = serializer2
+ # Try to validate it with tenant_id="tenant-2" (must fail)
+ manager2 = SessionManager(
+ backend,
+ config,
+ token_serializer=MultiTenantJWTSerializer(config, tenant_id="tenant-2"),
+ )
- with pytest.raises(ValueError, match="Invalid tenant_id"):
+ # The serializer's ValueError surfaces as SessionTokenError
+ with pytest.raises(SessionTokenError, match="Invalid tenant_id"):
await manager2.get_session(token)
```
-## 常見問題
+## FAQ
+
+### Q: Why isn't `jti` implemented by default?
-### Q: 為什麼不預設實作 `jti`?
+A: `jti` is mainly used to revoke stateless JWTs (via a blacklist). FastAPI-CacheX uses stateful sessions, so a token can be revoked by deleting the server-side session data directly; no separate blacklist mechanism is needed.
-A: `jti` 主要用於 stateless JWT 的 token 撤銷(blacklist)。FastAPI-CacheX 使用 stateful session,可以直接刪除伺服器端的 session 資料來撤銷 token,不需要額外的 blacklist 機制。
+### Q: Do I need `nbf`?
-### Q: 我需要 `nbf` 嗎?
+A: In most cases, no. `nbf` is for tokens that are issued in advance but become valid later. If your application needs this, we recommend handling it in the application logic (for example, recording the activation time in `session.data`) rather than at the JWT level.
-A: 大多數情況下不需要。`nbf` 用於預先簽發但延遲生效的 token。如果您的應用需要這個功能,建議在應用邏輯層處理(例如在 session.data 中記錄生效時間),而不是在 JWT 層面。
+### Q: Can I add claims without writing code?
-### Q: 能否在不修改程式碼的情況下新增 claims?
+A: Not currently; custom claims require subclassing `JWTTokenSerializer`. A future version might add a configuration option such as the hypothetical one below (it does not exist today, and `SessionConfig` rejects unknown fields):
-A: 目前需要透過繼承 `JWTTokenSerializer` 來新增自訂 claims。未來版本可能會考慮新增配置選項,例如:
```python
SessionConfig(
token_format="jwt",
jwt_custom_claims={"tenant_id": "acme", "version": "v1"},
)
```
-但這會增加複雜度。目前的設計提供了足夠的靈活性,同時保持程式碼簡潔。
-### Q: 自訂 claims 會影響效能嗎?
+That would add complexity, though. The current design offers enough flexibility while keeping the code simple.
-A: 影響很小。JWT 編碼/解碼的效能主要取決於:
-1. 加密演算法(HS256 很快)
-2. Token 大小(更多 claims = 更大)
-3. 網路傳輸(更大的 token)
+### Q: Do custom claims affect performance?
-只要不新增大量資料,影響可以忽略。
+A: Only slightly. JWT encoding/decoding performance depends mainly on:
-### Q: 如何在 JWT 中包含使用者權限?
+1. The signing algorithm (HS256 is fast)
+2. Token size (more claims = larger token)
+3. Network transfer (larger tokens)
+
+As long as you don't add large amounts of data, the impact is negligible.
+
+### Q: How do I include user permissions in the JWT?
+
+A: We don't recommend putting permissions in the JWT. FastAPI-CacheX uses stateful sessions, so you should:
-A: 不建議在 JWT 中包含權限資訊。FastAPI-CacheX 採用 stateful session,應該:
```python
-# ✅ 推薦:儲存在 server-side session
+# ✅ Recommended: store them in the server-side session
session.user.roles = ["admin", "editor"]
-session.data["permissions"] = ["read", "write", "delete"]
+session.user.permissions = ["read", "write", "delete"]
await manager.update_session(session)
-# ❌ 不推薦:放在 JWT claims
-# 權限變更時無法即時更新,除非撤銷所有現有 token
+# ❌ Not recommended: putting them in JWT claims
+# Permission changes cannot take effect immediately unless every existing token is revoked
```
-## 參考資料
+## References
- [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)
- [PyJWT Documentation](https://pyjwt.readthedocs.io/)
- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
- [FastAPI-CacheX Session Documentation](SESSION.md)
-## 總結
-
-FastAPI-CacheX 的 JWT 實作專注於 **stateful session** 場景,提供:
-
-✅ **已實作**:基本 JWT claims(sid, iat, exp, iss, aud)
-✅ **已實作**:簽名驗證與過期檢查
-✅ **已實作**:可擴展架構(透過繼承)
+## Summary
-⚠️ **未實作**:jti, nbf, sub(這些在 stateful session 中不是必需的)
+FastAPI-CacheX's JWT implementation focuses on the **stateful session** use case and provides:
-🔧 **可擴展**:開發者可以輕鬆新增自訂 claims(見本文件範例)
+- ✅ **Implemented**: the basic JWT claims (`sid`, `iat`, `exp`, `iss`, `aud`)
+- ✅ **Implemented**: signature verification and expiry checks
+- ✅ **Implemented**: an extensible design (via subclassing and the `token_serializer` argument)
+- ⚠️ **Not implemented**: `jti`, `nbf`, `sub` (these are not required for stateful sessions)
+- 🔧 **Extensible**: developers can easily add custom claims (see the examples in this document)
-這種設計在安全性、效能和靈活性之間取得了良好的平衡。如果您的應用有特殊需求,請參考本文件的擴展範例。
+This design strikes a good balance between security, performance, and flexibility. If your application has special requirements, refer to the extension examples in this document.
diff --git a/docs/SESSION.md b/docs/SESSION.md
index 96c0562..3150da8 100644
--- a/docs/SESSION.md
+++ b/docs/SESSION.md
@@ -1,125 +1,129 @@
# Session Management Extension
-FastAPI-CacheX Session Management 提供完整的使用者 Session 管理功能,包含安全的 token 簽名、
-自動展期、IP/User-Agent 綁定等特性。Session 內容一律存在後端(cache backend),客戶端只持有
-一枚已簽名的 Token。
-
-Token 的**傳輸方式取決於你掛哪一個 middleware**:
-
-| Middleware | Token 來源 | 回應側 | 狀態 |
-|------------|-----------|--------|------|
-| `FastAPICacheXSessionMiddleware` | 自訂 Header(預設 `X-Session-Token`)/`Authorization: Bearer`/**Cookie**(預設名稱 `session`) | 依來源分流:Header 進來就回 Header,Cookie 進來(或全新 Session)就 `Set-Cookie` | **建議使用** |
-| `SessionMiddleware` | 自訂 Header/`Authorization: Bearer`,**不支援 Cookie** | 續期 Token 以回應 Header 回送 | 已 deprecated,**0.4.0 移除** |
-
-**新專案請一律使用 `FastAPICacheXSessionMiddleware`。** 它涵蓋 `SessionMiddleware` 的
-全部傳輸方式(同樣讀 `X-Session-Token` 與 `Authorization: Bearer`),額外支援 Cookie;
-`SessionMiddleware` 自 0.3.1 起建構時就會發出 `DeprecationWarning`,並將在 **0.4.0 移除**。
-兩者的 Session 依賴項(`get_session`、`get_optional_session`、`require_session`)完全相同,
-遷移通常只需要換掉 `add_middleware` 的那一行,既有以 Header 傳 token 的客戶端不必改動。
-
-`SessionConfig` 的六個 `cookie_*` 設定(`cookie_name`、`cookie_max_age`、`cookie_path`、
-`cookie_same_site`、`cookie_https_only`、`cookie_domain`)**只有 `FastAPICacheXSessionMiddleware`
-會讀**;掛 `SessionMiddleware` 時設了也不會有任何效果。
-
-## 特性
-
-- ✅ **Session 生命週期管理**:建立、讀取、更新、刪除
-- ✅ **安全機制**:
- - HMAC-SHA256 Token 簽名
- - IP 地址綁定(可選)
- - User-Agent 綁定(可選)
- - 登入後自動重新生成 Session ID
-- ✅ **多種 Token 來源**:自訂 Header、`Authorization: Bearer`、Cookie
- (Cookie 僅 `FastAPICacheXSessionMiddleware` 支援)
-- ✅ **可選 JWT 格式**:支援以 JWT 作為 Session Token(需安裝 extra `jwt`)
-- ✅ **自動展期**:滑動過期時間支援
-- ✅ **Flash Messages**:跨請求訊息傳遞
-- ✅ **多後端支援**:Redis、Memcached、In-Memory
-- ✅ **API-first 或瀏覽器架構皆可**:Token 可由客戶端自行保管(Header/Bearer),
- 也可交給瀏覽器以 Cookie 保管(`FastAPICacheXSessionMiddleware`)
-
-## 快速開始
-
-### 1. 安裝
-
-Session 管理已整合在 FastAPI-CacheX 中:
+FastAPI-CacheX Session Management provides complete user session handling, including signed
+tokens, sliding expiration, and optional IP/User-Agent binding. Session contents always live in
+the cache backend; the client only holds a single signed token.
+
+**How the token travels depends on which middleware you install:**
+
+| Middleware | Token source | Response side | Status |
+|------------|--------------|---------------|--------|
+| `FastAPICacheXSessionMiddleware` | Custom header (default `X-Session-Token`) / `Authorization: Bearer` / **cookie** (default name `session`) | Routed by source: a token that arrived in a header is returned in the response header; a token that arrived in a cookie (or a brand-new session) gets `Set-Cookie` | **Recommended** |
+| `SessionMiddleware` | Custom header / `Authorization: Bearer`; **no cookie support** | A renewed token is sent back in the response header | Deprecated, **removed in 0.4.0** |
+
+**Use `FastAPICacheXSessionMiddleware` for all new projects.** It covers every transport of
+`SessionMiddleware` (it reads `X-Session-Token` and `Authorization: Bearer` in the same way) and
+adds cookie support. Since 0.3.1, `SessionMiddleware` emits a `DeprecationWarning` when it is
+constructed, and it will be **removed in 0.4.0**. Both middlewares feed the same session
+dependencies (`get_session`, `get_optional_session`, `require_session`), so migrating usually only
+means changing the `add_middleware` line; existing clients that send the token in a header need no
+changes.
+
+The six `cookie_*` settings of `SessionConfig` (`cookie_name`, `cookie_max_age`, `cookie_path`,
+`cookie_same_site`, `cookie_https_only`, `cookie_domain`) are **read only by
+`FastAPICacheXSessionMiddleware`**; setting them has no effect when `SessionMiddleware` is installed.
+
+## Features
+
+- ✅ **Session lifecycle management**: create, read, update, delete, invalidate
+- ✅ **Security**:
+ - HMAC-SHA256 token signing
+ - IP address binding (optional)
+ - User-Agent binding (optional)
+ - Session ID regeneration after login
+- ✅ **Multiple token sources**: custom header, `Authorization: Bearer`, cookie
+ (cookies are supported by `FastAPICacheXSessionMiddleware` only)
+- ✅ **Optional JWT format**: use a JWT as the session token (requires the `jwt` extra)
+- ✅ **Sliding expiration**, plus an optional absolute timeout
+- ✅ **Flash messages**: pass messages across requests
+- ✅ **Multiple backends**: Redis, Memcached, in-memory
+- ✅ **API-first or browser-based architectures**: the client can keep the token itself
+ (header/bearer), or leave it to the browser as a cookie (`FastAPICacheXSessionMiddleware`)
+
+## Quick Start
+
+### 1. Installation
+
+Session management is built into FastAPI-CacheX:
```bash
uv add fastapi-cachex
```
-若要啟用 JWT Token 格式支援:
+To enable the JWT token format:
```bash
uv add "fastapi-cachex[jwt]"
```
-### 2. 基本使用
+### 2. Basic Usage
```python
-from fastapi import FastAPI
+from fastapi import Depends, FastAPI, HTTPException
from fastapi_cachex.backends import MemoryBackend
from fastapi_cachex.session import (
- SessionManager,
FastAPICacheXSessionMiddleware,
SessionConfig,
+ SessionManager,
SessionUser,
- get_session,
get_optional_session,
+ get_session,
)
-# 初始化 FastAPI 應用
+# Create the FastAPI application
app = FastAPI()
-# 設定 Session 配置(API-first 架構:客戶端管理 Token)
+# Session configuration (API-first architecture: the client manages the token)
config = SessionConfig(
- secret_key="your-secret-key-min-32-chars-long!!!",
+ secret_key="your-secret-key-min-32-chars-long!!!", # at least 32 characters
session_ttl=3600, # 1 hour
)
-# 設定後端和 Session Manager
+# Set up the backend and the session manager
backend = MemoryBackend()
session_manager = SessionManager(backend, config)
-# 新增 Session Middleware(SessionMiddleware 已 deprecated,將於 0.4.0 移除)
+# Add the session middleware (SessionMiddleware is deprecated and removed in 0.4.0)
app.add_middleware(
FastAPICacheXSessionMiddleware,
session_manager=session_manager,
config=config,
)
-# 或者使用 Proxy(可選)
-from fastapi_cachex.session import SessionManagerProxy
+# Alternatively, register the manager on the proxy instead of passing it in:
+#
+# from fastapi_cachex.session import SessionManagerProxy
+#
+# SessionManagerProxy.set(session_manager)
+# app.add_middleware(FastAPICacheXSessionMiddleware) # picked up from the proxy
+#
+# When `config` is omitted, the middleware uses `session_manager.config`.
-SessionManagerProxy.set(session_manager)
-app.add_middleware(FastAPICacheXSessionMiddleware) # 自動從 Proxy 取得
-
-
-# 登入端點
+# Login endpoint
@app.post("/login")
async def login(username: str, password: str):
- # 驗證使用者(這裡簡化處理)
- if username == "admin" and password == "secret":
- # 建立 Session
- user = SessionUser(
- user_id="123",
- username=username,
- roles=["admin"],
- )
- session, token = await session_manager.create_session(user=user)
+ # Authenticate the user (simplified here)
+ if username != "admin" or password != "secret":
+ raise HTTPException(status_code=401, detail="Invalid credentials")
- # 返回 token 供客戶端儲存(localStorage/sessionStorage)
- # 客戶端應在後續請求中透過 Authorization header 或 X-Session-Token header 傳送
- return {"message": "Login successful", "token": token}
+ # Create the session
+ user = SessionUser(
+ user_id="123",
+ username=username,
+ roles=["admin"],
+ )
+ session, token = await session_manager.create_session(user=user)
- return {"error": "Invalid credentials"}, 401
+ # Return the token for the client to store (localStorage/sessionStorage).
+ # The client sends it on later requests in the Authorization or X-Session-Token header.
+ return {"message": "Login successful", "token": token}
-# 需要認證的端點
+# Endpoint that requires authentication
@app.get("/profile")
async def get_profile(session=Depends(get_session)):
- """需要有效 session 才能存取"""
+ """Requires a valid session."""
return {
"user_id": session.user.user_id,
"username": session.user.username,
@@ -127,53 +131,63 @@ async def get_profile(session=Depends(get_session)):
}
-# 可選認證的端點
+# Endpoint with optional authentication
@app.get("/public")
async def public_endpoint(session=Depends(get_optional_session)):
- """可以有或沒有 session 存取"""
- if session:
+ """Accessible with or without a session."""
+ if session and session.user:
return {"message": f"Hello, {session.user.username}!"}
return {"message": "Hello, guest!"}
-# 登出端點
+# Logout endpoint
@app.post("/logout")
async def logout(session=Depends(get_session)):
await session_manager.delete_session(session.session_id)
return {"message": "Logged out"}
```
-### 3. 完整範例(含 Redis 後端)
+`get_session` (and its alias `require_session`) raises `401 Authentication required` with a
+`WWW-Authenticate: Bearer` header when the request carries no valid session. A token that is
+malformed, forged, expired, invalidated or fails a binding check is never an error at the
+middleware level: the request simply proceeds without a session.
+
+The session object the dependencies return is the backend `Session` model. A session created by
+`FastAPICacheXSessionMiddleware` from `request.session` (see the Migration section below) is
+anonymous, so `session.user` is `None`.
+
+### 3. Full Example (Redis Backend)
```python
-from fastapi import FastAPI, Depends, HTTPException, Request, Response
+from datetime import datetime, timezone
+
+from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi_cachex.backends import AsyncRedisCacheBackend
from fastapi_cachex.session import (
- SessionManager,
FastAPICacheXSessionMiddleware,
SessionConfig,
+ SessionManager,
SessionUser,
get_session,
- get_optional_session,
)
app = FastAPI()
-# Redis 後端配置
+# Redis backend
backend = AsyncRedisCacheBackend(
host="localhost",
port=6379,
db=0,
)
-# Session 配置(含安全選項)
+# Session configuration with security options
config = SessionConfig(
secret_key="your-very-secret-key-at-least-32-characters-long!!",
session_ttl=3600,
sliding_expiration=True,
sliding_threshold=0.5,
- ip_binding=True, # 啟用 IP 綁定
- user_agent_binding=False, # UA 綁定(可選)
+ ip_binding=True, # enable IP binding
+ user_agent_binding=False, # UA binding (optional)
)
session_manager = SessionManager(backend, config)
@@ -187,11 +201,11 @@ app.add_middleware(
@app.post("/api/auth/login")
async def login(username: str, password: str, request: Request):
- # 驗證使用者(應該查詢資料庫)
+ # Authenticate the user (should query a database)
if not authenticate_user(username, password):
raise HTTPException(status_code=401, detail="Invalid credentials")
- # 建立 Session
+ # Create the session
user = SessionUser(
user_id=get_user_id(username),
username=username,
@@ -199,7 +213,8 @@ async def login(username: str, password: str, request: Request):
roles=get_user_roles(username),
)
- # 獲取客戶端資訊
+ # Collect client information for the bindings. See "Client IP and reverse
+ # proxies" below if the app runs behind a proxy.
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
@@ -209,13 +224,13 @@ async def login(username: str, password: str, request: Request):
user_agent=user_agent,
)
- # 新增 flash message
+ # Add a flash message
session.add_flash_message("Login successful!", "success")
await session_manager.update_session(session)
return {
"message": "Login successful",
- "token": token, # 客戶端應儲存此 token 並在後續請求中使用
+ "token": token, # the client stores this token and sends it on later requests
"user": {
"username": user.username,
"roles": user.roles,
@@ -225,7 +240,7 @@ async def login(username: str, password: str, request: Request):
@app.get("/api/user/profile")
async def get_user_profile(session=Depends(get_session)):
- """獲取使用者資料(需要認證)"""
+ """Return the user's profile (requires authentication)."""
return {
"user_id": session.user.user_id,
"username": session.user.username,
@@ -241,12 +256,11 @@ async def update_user_profile(
email: str,
session=Depends(get_session),
):
- """更新使用者資料"""
- # 更新使用者資料
+ """Update the user's profile."""
session.user.email = email
session.data["last_updated"] = datetime.now(timezone.utc).isoformat()
- # 儲存更新後的 session
+ # Persist the updated session
await session_manager.update_session(session)
return {"message": "Profile updated"}
@@ -254,69 +268,81 @@ async def update_user_profile(
@app.get("/api/messages")
async def get_flash_messages(session=Depends(get_session)):
- """獲取 flash messages"""
+ """Return and clear the flash messages."""
messages = session.get_flash_messages(clear=True)
+ # Clearing only changes the in-memory object; save it so the messages
+ # are not shown again on the next request.
+ await session_manager.update_session(session)
return {"messages": messages}
@app.post("/api/auth/logout")
async def logout(session=Depends(get_session)):
- """登出"""
+ """Log out."""
await session_manager.delete_session(session.session_id)
- # 客戶端應該清除儲存的 token
+ # The client should discard its stored token
return {"message": "Logged out successfully"}
@app.post("/api/auth/logout-all")
async def logout_all_devices(session=Depends(get_session)):
- """登出所有裝置"""
+ """Log out from all devices."""
user_id = session.user.user_id
count = await session_manager.delete_user_sessions(user_id)
return {"message": f"Logged out from {count} devices"}
-# 輔助函式(示意)
+# Helper functions (illustrative only)
def authenticate_user(username: str, password: str) -> bool:
- # 實際應該查詢資料庫並驗證密碼雜湊
+ # A real implementation queries the database and verifies the password hash
return True
def get_user_id(username: str) -> str:
- # 實際應該從資料庫獲取
+ # A real implementation reads this from the database
return f"user_{username}"
def get_user_roles(username: str) -> list[str]:
- # 實際應該從資料庫獲取
+ # A real implementation reads this from the database
return ["user"] if username != "admin" else ["admin", "user"]
```
+Changes made to a `Session` object inside a handler (flash messages, `session.data`,
+`session.user`) are only persisted when you call `session_manager.update_session(session)`.
+
+`delete_user_sessions()` and `clear_expired_sessions()` enumerate every key in the backend via
+`get_all_keys()` and load each session under `backend_key_prefix`, so their cost grows with the
+size of the backend. On the Memcached backend, which cannot enumerate keys, they find nothing and
+return `0` (with a `RuntimeWarning` from the backend).
+
## Migration: SessionMiddleware → FastAPICacheXSessionMiddleware
-`SessionMiddleware` 已自 0.3.1 版本起標記為 deprecated(建構時會發出
-`DeprecationWarning`),並將在 0.4.0 版本移除,請改用
-`FastAPICacheXSessionMiddleware`:
-
-- **`SessionMiddleware`**(`BaseHTTPMiddleware`):透過自訂 Header(預設
- `X-Session-Token`)與/或 `Authorization: Bearer` 傳遞 Token,適用於
- API-first、由客戶端管理 Token 的架構。不支援 Cookie 傳輸。
-- **`FastAPICacheXSessionMiddleware`**:與 Starlette 內建的
- `SessionMiddleware` 相容,透過 Cookie(預設 cookie 名稱 `session`)傳遞
- 已簽名的 Session Token,Session 內容則儲存於後端(`SessionManager`
- 對應的 cache backend),而非如 Starlette 原生做法般編碼進 Cookie 本身。
- Token 解析採「Header 優先、Cookie 其次」:會先讀取自訂 Header(預設
- `X-Session-Token`)與/或 `Authorization: Bearer`,找不到才回退到 Cookie,
- 因此原本使用 `SessionMiddleware` 之 `X-Session-Token` 的客戶端可直接沿用。
- 回應側也依來源分流:以 Header 帶入的 Token,其續期後的新 Token 會透過同一個
- 回應 Header 回送、且不發送 `Set-Cookie`;以 Cookie 帶入(或全新的匿名
- Session)則沿用 `Set-Cookie`。
-
-兩者都會將已載入的 `Session` 物件放進 `request.state`,因此既有的
-Session 依賴項 `get_session`、`get_optional_session`、`require_session`
-在兩種 Middleware 下皆可正常運作,無需額外調整:
+`SessionMiddleware` has been deprecated since 0.3.1 (it emits a `DeprecationWarning` when
+constructed) and will be removed in 0.4.0. Use `FastAPICacheXSessionMiddleware` instead:
+
+- **`SessionMiddleware`** (a `BaseHTTPMiddleware`): passes the token in a custom header (default
+ `X-Session-Token`) and/or `Authorization: Bearer`, suited to API-first architectures where the
+ client manages the token. Cookie transport is not supported.
+- **`FastAPICacheXSessionMiddleware`** (a pure ASGI middleware): compatible with Starlette's
+ built-in `SessionMiddleware`, exposing the same dict-like `request.session`. It passes the signed
+ session token in a cookie (default cookie name `session`), while the session contents are stored
+ in the backend (the cache backend of the `SessionManager`) rather than encoded into the cookie
+ itself as Starlette's own implementation does. Token resolution is "header first, cookie
+ second": it reads the custom header (default `X-Session-Token`) and/or `Authorization: Bearer`
+ first and only falls back to the cookie when neither is present, so clients that used
+ `X-Session-Token` with `SessionMiddleware` keep working unchanged. The response side is routed
+ by source too: for a token that arrived in a header, a renewed token is sent back in the same
+ response header and no `Set-Cookie` is emitted; a token that arrived in a cookie (or a brand-new
+ anonymous session) uses `Set-Cookie`.
+
+Both middlewares put the loaded `Session` object into `request.state`, so the existing session
+dependencies `get_session`, `get_optional_session` and `require_session` work under either
+middleware without any changes:
```python
+from fastapi import Depends
from fastapi_cachex.session import FastAPICacheXSessionMiddleware, get_session
app.add_middleware(
@@ -329,73 +355,106 @@ async def me(session=Depends(get_session)):
return {"user_id": session.user.user_id}
```
-## 配置選項
+### `request.session` with `FastAPICacheXSessionMiddleware`
+
+`request.session` is a view of the backend session's `data` dict:
+
+- Writing to `request.session` when no session was loaded creates a new **anonymous** session
+ (`SessionManager.create_anonymous_session()`, with IP/User-Agent bindings applied as
+ configured) and sends its token back through the request's transport.
+- Modifying it on a loaded session saves the new contents to the backend via `update_session()`,
+ replacing `Session.data` with the dict's contents.
+- Clearing it (`request.session.clear()`) on a session that had data deletes the backend session;
+ a cookie client also receives a `Set-Cookie` that expires the cookie.
+- Any access to `request.session` adds `Vary: Cookie` to the response.
+
+The cookie is always `HttpOnly`; `Secure`, `SameSite`, `Domain`, `Path` and `Max-Age` follow the
+`cookie_*` settings.
+
+## Configuration
### SessionConfig
+`SessionConfig` is a Pydantic model that rejects unknown fields (`extra="forbid"`), so a typo in
+a field name raises a `ValidationError`. The values below are the defaults, except `secret_key`,
+which is required.
+
```python
SessionConfig(
- # Session 生命週期
- session_ttl=3600, # Session TTL(秒)
- absolute_timeout=None, # 絕對過期時間(秒)
- sliding_expiration=True, # 滑動過期
- sliding_threshold=0.5, # 滑動閾值(0.5 = TTL 過半時更新)
- # Token 來源(API-first 架構)
- token_format="simple", # 可選:"simple"(預設)、"jwt"
+ # Session lifetime
+ session_ttl=3600, # session TTL (seconds)
+ absolute_timeout=None, # hard cap measured from created_at (seconds); None = no cap
+ sliding_expiration=True, # sliding expiration
+ sliding_threshold=0.5, # 0.0-1.0; renew once less than this fraction of the TTL remains
+ # Token sources (API-first architecture)
+ token_format="simple", # "simple" (default) or "jwt"
header_name="X-Session-Token",
use_bearer_token=True,
- token_source_priority=["header", "bearer"], # 只接受這兩個值(見下方說明)
- # JWT(token_format == "jwt" 時使用)
- jwt_algorithm="HS256",
- jwt_issuer=None, # 若設定,解析時會驗證 iss
- jwt_audience=None, # 若設定,解析時會驗證 aud
- jwt_leeway=60, # exp/iat 驗證的容忍秒數(本套件不簽發也不驗證 nbf)
- # 安全設定
- secret_key="...", # 必須:至少 32 字元
- ip_binding=False, # IP 綁定
- user_agent_binding=False, # User-Agent 綁定
- trusted_proxies=[], # 可信任的反向代理位址(見下方「客戶端 IP 與反向代理」)
- # 後端設定
+ token_source_priority=["header", "bearer"], # only these two values (see below)
+ # JWT (used when token_format == "jwt")
+ jwt_algorithm="HS256", # "none" is rejected
+ jwt_issuer=None, # if set, iss is written and verified on parsing
+ jwt_audience=None, # if set, aud is written and verified on parsing
+ jwt_leeway=0, # tolerance in seconds for exp/iat checks (nbf is neither issued nor verified)
+ # Security
+ secret_key="...", # required: at least 32 characters
+ ip_binding=False, # IP binding
+ user_agent_binding=False, # User-Agent binding
+ trusted_proxies=[], # trusted reverse proxy addresses (see "Client IP and reverse proxies")
+ # Backend
backend_key_prefix="session:",
- # Cookie 設定(只有 FastAPICacheXSessionMiddleware 會讀)
+ # Cookies (read only by FastAPICacheXSessionMiddleware)
cookie_name="session",
- cookie_max_age=14 * 24 * 60 * 60, # None 代表瀏覽器關閉即失效
+ cookie_max_age=14
+ * 24
+ * 60
+ * 60, # None = no Max-Age (cookie ends with the browser session)
cookie_path="/",
cookie_same_site="lax", # "lax" / "strict" / "none"
- cookie_https_only=False, # True 會加上 Secure
- cookie_domain=None, # None 代表不輸出 Domain 屬性
+ cookie_https_only=False, # True adds the Secure flag
+ cookie_domain=None, # None = no Domain attribute
)
```
-#### `token_source_priority` 只接受 `"header"` 與 `"bearer"`
+Sessions expire after `session_ttl` seconds. With `sliding_expiration`, each request that finds
+less than `session_ttl * sliding_threshold` seconds remaining extends the expiry to a full
+`session_ttl` again and issues a renewed token, which the middleware sends back to the client
+(response header or `Set-Cookie`, see the table above). Header/bearer clients should replace their
+stored token when the response carries the `header_name` header. `absolute_timeout` ends the
+session that many seconds after it was created, regardless of sliding renewals.
+
+#### `token_source_priority` accepts only `"header"` and `"bearer"`
-這個欄位的型別是 `list[Literal["header", "bearer"]]`,填入 `"cookie"` 會被 pydantic
-直接擋下(`ValidationError`)。Cookie **不是**優先順序的一員:
-`FastAPICacheXSessionMiddleware` 的解析順序是固定的 —— 先照 `token_source_priority`
-讀 Header/Bearer,都沒有才回退到 Cookie。這是刻意的:回應側要依 Token 的來源分流
-(Header 進來就回 Header、Cookie 進來就 `Set-Cookie`),把 Cookie 混進同一份優先序
-會讓「只填 `["cookie"]`」在已 deprecated 的 `SessionMiddleware` 上變成無聲失效。
-待 0.4.0 移除 `SessionMiddleware` 後,三種來源可望統一由同一份優先序描述。
+The field's type is `list[Literal["header", "bearer"]]`; passing `"cookie"` is rejected by
+Pydantic with a `ValidationError`. The cookie is **not** part of the priority order:
+`FastAPICacheXSessionMiddleware` resolves tokens in a fixed order — it first reads
+header/bearer following `token_source_priority`, and only falls back to the cookie when neither
+yields a token. This is deliberate: the response side is routed by the token's source (header in,
+header out; cookie in, `Set-Cookie` out), and mixing the cookie into the same priority list would
+make a `["cookie"]` setting fail silently on the deprecated `SessionMiddleware`. Once
+`SessionMiddleware` is removed in 0.4.0, all three sources may be described by a single priority
+list.
-**Header/Bearer 客戶端**應將 token 儲存在 `localStorage` 或 `sessionStorage`,並於請求時以
-`Authorization: Bearer ` 或 `X-Session-Token: ` 傳送。**Cookie 客戶端**(瀏覽器)
-不需要自行處理 token,但要注意 CSRF:Cookie 會被瀏覽器自動附帶,請搭配 `cookie_same_site`
-與自有的 CSRF 防護。
+**Header/bearer clients** should store the token in `localStorage` or `sessionStorage` and send
+it as `Authorization: Bearer ` or `X-Session-Token: `. **Cookie clients** (browsers)
+do not need to handle the token themselves, but beware of CSRF: the browser attaches cookies
+automatically, so combine `cookie_same_site` with your own CSRF protection.
-### 使用 JWT Token 格式
+### Using the JWT Token Format
-啟用 `token_format="jwt"` 後,Session Token 會以 JWT 簽發,內含下列 claims:
+With `token_format="jwt"`, session tokens are issued as JWTs carrying these claims:
-- `sid`: Session ID(自訂 claim,用於對應伺服器端 Session)
-- `iat`: 簽發時間(epoch 秒)
-- `exp`: 過期時間(`iat + session_ttl`)
-- `iss`/`aud`: 若於設定中提供,則會被寫入並在解析時驗證
+- `sid`: session ID (custom claim, maps to the server-side session)
+- `iat`: issued-at time (epoch seconds)
+- `exp`: expiry time — the session's current `expires_at` (so it moves with sliding renewal),
+ falling back to `iat + session_ttl`
+- `iss`/`aud`: written when configured, and verified on parsing
-設定範例:
+Example configuration:
```python
config = SessionConfig(
- secret_key="your-secret-at-least-32-chars",
+ secret_key="your-secret-key-at-least-32-characters",
token_format="jwt",
jwt_algorithm="HS256",
jwt_issuer="your-issuer",
@@ -403,90 +462,133 @@ config = SessionConfig(
)
```
-安全性說明:
+`jwt_algorithm` must be one of `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`,
+`ES384`, `ES512`, `PS256`, `PS384`, `PS512` or `EdDSA`; anything else (including `none`) raises a
+`ValidationError`. The same `secret_key` is used to sign and verify tokens.
-- 伺服器端仍維持「有狀態」Session(JWT 只作為攜帶 `sid` 的憑證),避免將敏感資料放入 Token
-- 解析時會驗證簽章與必要 claims(`sid/iat/exp`,以及設定的 `iss/aud`)
-- 建議在生產環境使用 HTTPS 與金鑰輪替策略(可使用 `kid` 與多把金鑰的進階方案,未來可擴展)
+Security notes:
-**進階主題**:關於 JWT claims 的設計考量、為何沒有實作 `jti`/`nbf` 等可選 claims,以及如何擴展新增自訂 claims,請參考 **[JWT Claims 實作說明與擴展指南](JWT_CLAIMS.md)**。
+- The server keeps **stateful** sessions (the JWT is only a credential carrying the `sid`), so no
+ sensitive data needs to go into the token
+- Parsing verifies the signature and the required claims (`sid`/`iat`/`exp`, plus `iss`/`aud`
+ when configured)
+- Use HTTPS and a key rotation strategy in production (an advanced scheme with `kid` and
+ multiple keys is a possible future extension)
-## 安全最佳實踐
+**Advanced topics**: for the design of the JWT claims, why optional claims such as `jti`/`nbf` are
+not implemented, and how to add custom claims, see the
+**[JWT Claims implementation notes and extension guide](JWT_CLAIMS.md)**.
+
+## Security Best Practices
### 1. Secret Key
```python
import secrets
-# 生成安全的 secret key
+# Generate a secure secret key
secret_key = secrets.token_urlsafe(32)
config = SessionConfig(secret_key=secret_key)
```
+`secret_key` is stored as a `SecretStr` and must be at least 32 characters long. Load it from the
+environment or a secret store rather than hard-coding it; changing it invalidates every token
+issued so far.
+
### 2. HTTPS Only
-生產環境務必使用 HTTPS 傳輸 token:
+Always transport tokens over HTTPS in production. For cookie clients, mark the cookie `Secure`:
```python
config = SessionConfig(
secret_key="...",
+ cookie_https_only=True, # adds the Secure flag to the session cookie
)
```
-**客戶端注意事項**:
-- 僅透過 HTTPS 傳輸 token
-- 使用 `httpOnly` 選項(如果使用 cookie 儲存)來防止 XSS
-- 避免在 URL 中傳遞 token
+**Client-side notes**:
-### 3. 客戶端 IP 與反向代理
+- Only send the token over HTTPS
+- The session cookie set by `FastAPICacheXSessionMiddleware` is always `HttpOnly`, so page scripts
+ cannot read it; a token kept in `localStorage`/`sessionStorage` is readable by scripts, so guard
+ against XSS
+- Avoid passing the token in URLs
-`ip_binding` 與稽核記錄取用的「客戶端 IP」**預設只信任直連的對端位址**,
-`X-Forwarded-For` 與 `X-Real-IP` 一律忽略 —— 任何人都能自行送出這兩個標頭。
+### 3. Client IP and Reverse Proxies
-部署在反向代理後面時,把代理的位址填進 `trusted_proxies`:
+The "client IP" used by `ip_binding` and for audit logging **trusts only the directly connected
+peer address by default**; `X-Forwarded-For` and `X-Real-IP` are ignored, because anyone can send
+those headers.
+
+When deploying behind a reverse proxy, put the proxy's address into `trusted_proxies`:
```python
config = SessionConfig(
secret_key="...",
ip_binding=True,
- trusted_proxies=["10.0.0.8"], # 直連進來的那一跳
+ trusted_proxies=["10.0.0.8"], # the hop that connects directly
)
```
-此時客戶端位址取自 `X-Forwarded-For` **最右側、且不在 `trusted_proxies` 裡**的那一筆:
-代理是往後附加的,最左側那筆是呼叫端自己選擇送出的內容,無法採信。若整條鏈都是可信代理,
-則退回直連對端位址。`X-Real-IP` 由代理自己寫入、沒有鏈可走,僅在 `X-Forwarded-For` 沒有
-可用值時採用。
+The client address is then the **rightmost `X-Forwarded-For` entry that is not listed in
+`trusted_proxies`**: proxies append to the header, so the leftmost entry is whatever the caller
+chose to send and cannot be trusted. If every entry in the chain is a trusted proxy, the direct
+peer address is used. `X-Real-IP` is written by the proxy itself and has no chain to walk, so it is
+used only when `X-Forwarded-For` yields no usable value.
> [!NOTE]
-> `trusted_proxies` 目前以**字串完全比對**,不支援 CIDR 網段。
+> `trusted_proxies` currently uses **exact string matching**; CIDR ranges are not supported.
+
+The middleware applies this logic when it checks a binding, but `create_session()` binds whatever
+`ip_address` you pass it. Behind a trusted proxy, `request.client.host` is the proxy's address,
+so pass the same client address the middleware will derive, or the binding check fails on the
+next request.
-### 4. IP 綁定(可選)
+### 4. IP Binding (Optional)
-提高安全性但可能影響使用者體驗(例如 IP 變動):
+Improves security but can hurt the user experience (for example when the client's IP changes):
```python
config = SessionConfig(
secret_key="...",
- ip_binding=True, # Session 綁定到客戶端 IP
+ ip_binding=True, # bind the session to the client IP
)
```
-### 5. 登入後重新生成 Session ID
+The binding is recorded when the session is created, from the `ip_address` passed to
+`create_session()` (or `user_agent` for `user_agent_binding`). If the value is missing at creation
+time, a warning is logged and the session is created unbound. A request whose address does not
+match the bound one (or has no address) is treated as having no session.
+
+### 5. Regenerate the Session ID After Login
-防止 Session Fixation 攻擊:
+Prevents session fixation attacks:
```python
-# 登入成功後
-session, old_token = await session_manager.get_session(current_token)
+# After a successful login
+session, _renewed_token = await session_manager.get_session(current_token)
session, new_token = await session_manager.regenerate_session_id(session)
+# Hand new_token to the client; the old token no longer resolves to a session.
```
-## API 參考
+`regenerate_session_id()` deletes the backend record under the old ID and saves the session under
+a new ID, keeping its data, user, `created_at` and expiry.
+
+## API Reference
+
+See also the generated [Session API reference](api/session.md).
### SessionManager
+```python
+SessionManager(
+ backend: BaseCacheBackend,
+ config: SessionConfig,
+ token_serializer: TokenSerializer | None = None, # overrides the simple/jwt choice
+)
+```
+
#### create_session()
```python
async def create_session(
@@ -497,6 +599,19 @@ async def create_session(
) -> tuple[Session, str]:
```
+Returns `(session, token)`. `extra_data` becomes the initial `session.data`.
+
+#### create_anonymous_session()
+```python
+async def create_anonymous_session(
+ ip_address: str | None = None,
+ user_agent: str | None = None,
+ **extra_data: object,
+) -> tuple[Session, str]:
+```
+
+Same as `create_session()` but with `session.user` set to `None`.
+
#### get_session()
```python
async def get_session(
@@ -506,7 +621,12 @@ async def get_session(
) -> tuple[Session, str | None]:
```
-Returns a tuple of `(session, renewed_token)`. `renewed_token` is non-`None` only when sliding expiration triggered a token renewal; the caller should propagate it to the client (e.g. via a response header).
+Returns a tuple of `(session, renewed_token)`. `renewed_token` is non-`None` only when sliding
+expiration triggered a token renewal; the caller should propagate it to the client (e.g. via a
+response header). Raises a subclass of `SessionError` (from `fastapi_cachex.session.exceptions`)
+on failure: `SessionTokenError` (malformed token), `SessionSecurityError` (bad signature or
+binding mismatch), `SessionNotFoundError`, `SessionInvalidError` (session not active) or
+`SessionExpiredError` (TTL or absolute timeout exceeded).
#### update_session()
```python
@@ -518,6 +638,14 @@ async def update_session(session: Session) -> None:
async def delete_session(session_id: str) -> None:
```
+#### invalidate_session()
+```python
+async def invalidate_session(session: Session) -> None:
+```
+
+Marks the session as invalidated and saves it; later lookups raise `SessionInvalidError` until
+the record expires.
+
#### regenerate_session_id()
```python
async def regenerate_session_id(
@@ -525,19 +653,48 @@ async def regenerate_session_id(
) -> tuple[Session, str]:
```
+#### delete_user_sessions() / clear_expired_sessions()
+```python
+async def delete_user_sessions(user_id: str) -> int:
+async def clear_expired_sessions() -> int:
+```
+
+Return the number of sessions deleted (see the key-enumeration note under the full example).
+
### Dependencies
```python
from fastapi_cachex.session import (
- get_session, # 需要認證(無 session 時返回 401)
- get_optional_session, # 可選認證(無 session 時返回 None)
- require_session, # 別名:get_session
+ get_session, # authentication required (401 when there is no session)
+ get_optional_session, # optional authentication (None when there is no session)
+ require_session, # alias of get_session
+ get_session_manager, # the SessionManager registered by the middleware
)
# Type annotations
from fastapi_cachex.session.dependencies import (
- RequiredSession,
- OptionalSession,
- SessionDep,
+ OptionalSession, # Session | None
+ RequiredSession, # Session
+ SessionDep, # Session
+ SessionManagerDep, # SessionManager
)
```
+
+`get_session_manager` returns the manager the middleware stored on `app.state` when it handled
+its first request; it responds with `500` if no session middleware has run yet. Using it avoids
+importing the manager into your route modules:
+
+```python
+from fastapi_cachex.session import SessionUser
+from fastapi_cachex.session.dependencies import SessionManagerDep
+
+
+@app.post("/login")
+async def login(username: str, manager: SessionManagerDep):
+ session, token = await manager.create_session(user=SessionUser(user_id=username))
+ return {"token": token}
+```
+
+`get_session` and `get_optional_session` also declare an `HTTPBearer` security scheme
+(`SessionBearer`), so Swagger UI shows an **Authorize** button; the token itself is still read by
+the middleware.
diff --git a/docs/STATE.md b/docs/STATE.md
index aace2a5..0b08778 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -1,13 +1,18 @@
# State Management Extension
-`fastapi_cachex.state` 提供**一次性 state token**,用來擋 OAuth / OIDC 授權流程的
-CSRF:發起授權前先產生一枚隨機 state 並存進快取後端,callback 回來時**消費**它,
-消費過的 state 不能再被使用第二次。
+`fastapi_cachex.state` provides **one-time state tokens** that protect OAuth / OIDC
+authorization flows against CSRF. Before starting the authorization, generate a random
+state and store it in the cache backend. When the callback comes back, **consume** it.
+A consumed state cannot be used a second time.
-state 內容存在與 HTTP 快取同一組 backend 上,但使用獨立的金鑰前綴(預設
-`oauth_state:`),因此 `clear_prefix()` 等操作不會互相波及。
+States live on the same backend as the HTTP cache but under their own key prefix
+(`oauth_state:` by default), so namespaced operations such as `CacheManager.clear_prefix()`
+leave them alone. A backend-wide `clear()` (for example `BackendProxy.get().clear()`)
+does remove them, because it clears everything under the backend's namespace.
-## 快速開始
+Everything in this guide can also be imported from the top-level `fastapi_cachex` package.
+
+## Quick start
```python
from fastapi import FastAPI, HTTPException
@@ -32,11 +37,11 @@ async def login(states: StateManagerDep):
@app.get("/callback")
async def callback(state: str, code: str, states: StateManagerDep):
try:
- data = await states.consume_state(state) # 一次性:成功後即刪除
+ data = await states.consume_state(state) # one-time: deleted on retrieval
except (InvalidStateError, StateExpiredError) as e:
raise HTTPException(status_code=400, detail="Invalid state") from e
- # 換 token、建立 session ...
+ # Exchange the code for tokens, create a session ...
return {"next": data.metadata.get("next", "/")}
```
@@ -46,94 +51,119 @@ async def callback(state: str, code: str, states: StateManagerDep):
from fastapi_cachex.state import StateManager
states = StateManager(
- backend=None, # None 代表使用 BackendProxy.get()
- key_prefix="oauth_state:", # 金鑰前綴
- default_ttl=600, # 預設 10 分鐘
+ backend=None, # None means use BackendProxy.get()
+ key_prefix="oauth_state:", # key prefix
+ default_ttl=600, # default: 10 minutes
)
```
+With `backend=None` the backend is resolved **when the `StateManager` is constructed**,
+not on each call. If `BackendProxy.set(...)` has not been called yet, the constructor
+raises `BackendNotFoundError`. Configure the backend first.
+
### `create_state(ttl=None, metadata=None) -> str`
-產生一枚 `secrets.token_urlsafe(32)`(256 bits 熵)的 state 字串並存入後端,回傳該字串。
-`metadata` 是任意可 JSON 序列化的字典,會與 state 一起存放(例如授權後要導回的路徑)。
-`ttl` 未指定時用 `default_ttl`。
+Generates a state string with `secrets.token_urlsafe(32)` (256 bits of entropy), stores it
+in the backend and returns it. `metadata` is an arbitrary JSON-serializable dict stored
+alongside the state (for example, the path to redirect to after authorization). When `ttl`
+is omitted, `default_ttl` is used. The same TTL is applied both as the backend TTL and as
+the state's `expires_at`.
### `consume_state(state) -> StateData`
-**一次性消費**。以後端的原子操作 `get_and_delete()` 取出並刪除,所以多個並行呼叫
-同一枚 state 時**只有一個**拿得到 —— 重播的 callback 無法通過第二次。
+**One-time consumption.** The entry is retrieved and removed with the backend's atomic
+`get_and_delete()`, so when several concurrent calls present the same state **only one**
+gets it. A replayed callback cannot pass a second time.
-| 情況 | 行為 |
+| Situation | Behavior |
|------|------|
-| 不存在/已被消費 | `InvalidStateError` |
-| 取得但已過期 | `StateExpiredError`(條目同時已被刪除,不會留下殘骸) |
-| 內容無法解析 | `StateDataError`(條目同樣已被刪除) |
-| 正常 | 回傳 `StateData` |
+| Missing, already consumed, or already evicted by the backend TTL | `InvalidStateError` |
+| Retrieved but past its `expires_at` | `StateExpiredError` (the entry has been deleted too, nothing is left behind) |
+| Retrieved but the content is not valid `StateData` JSON | `StateDataError` (the entry has been deleted too) |
+| Otherwise | Returns `StateData` |
+
+In the common case the backend TTL removes an expired state first, so an expired state
+usually shows up as `InvalidStateError` instead of `StateExpiredError`; catch both. On
+Redis and Memcached, a stored value that cannot be decoded into a cache entry at all is
+treated as a miss by the backend, which also surfaces as `InvalidStateError`.
### `validate_state(state) -> bool`
-只看不消費:state 存在、可解析且未過期時回 `True`,否則 `False`。不會拋例外。
+Read-only check that does not consume the state: returns `True` when the state exists,
+can be parsed and has not expired, otherwise `False`. It does not raise state exceptions.
> [!WARNING]
-> `validate_state()` **不會**把 state 消費掉,所以它本身擋不住重播。
-> 真正的防護是 `consume_state()`;`validate_state()` 只適合用在「先探測、再決定 UI」
-> 這類非安全判斷。
+> `validate_state()` does **not** consume the state, so on its own it does not prevent replay.
+> The real protection is `consume_state()`. Use `validate_state()` only for non-security
+> decisions such as "probe first, then decide what UI to show".
### `get_state_metadata(state) -> dict | None`
-同樣不消費,回傳當初存入的 `metadata`;state 不存在/過期/無法解析時回 `None`。
+Also non-consuming. Returns the `metadata` stored at creation, or `None` when the state
+is missing, expired or cannot be parsed.
### `delete_state(state) -> bool`
-手動刪除(例如使用者取消授權)。回傳「原本是否存在」。
+Deletes a state manually (for example, when the user cancels the authorization). Returns
+whether the state existed. It uses the same atomic `get_and_delete()`, so it returns `True`
+to at most one caller even when racing with `consume_state()`.
## StateData
```python
class StateData(BaseModel):
- state: str # state 字串本身
- created_at: datetime # 建立時間(UTC)
- expires_at: datetime # 過期時間(UTC)
- metadata: dict[str, Any] # 建立時附帶的中繼資料
+ state: str # the state string itself
+ created_at: datetime # creation time (UTC)
+ expires_at: datetime # expiry time (UTC)
+ metadata: dict[str, Any] # metadata attached at creation
```
-`expires_at` 是存在資料內的邏輯過期時間,與後端 TTL 各自獨立:後端 TTL 到期會讓條目消失,
-而 `expires_at` 讓「後端還留著但邏輯上已過期」的條目一樣被拒絕。
+`expires_at` is a logical expiry stored inside the data, independent of the backend TTL.
+When the backend TTL runs out, the entry disappears. `expires_at` makes sure an entry the
+backend still holds but that is logically expired is rejected as well.
-## 依賴注入與 Proxy
+## Dependency injection and proxy
```python
from fastapi_cachex.state import StateManagerDep, StateManagerProxy, get_state_manager
-# 1. 直接用型別註解(最常見)
+# 1. Use the type annotation directly (most common)
@app.get("/login")
async def login(states: StateManagerDep): ...
-# 2. 自訂實例(例如換前綴或 TTL):啟動時註冊,依賴注入就會拿到它
+# 2. Custom instance (e.g. a different prefix or TTL): register it at startup
+# and dependency injection will return it
StateManagerProxy.set(StateManager(key_prefix="csrf:", default_ttl=300))
```
-`get_state_manager()`(`StateManagerDep` 背後的依賴)在沒有註冊過實例時,會延遲建立一個
-預設 `StateManager`(使用 `BackendProxy` 的後端)並註冊起來。
+When no instance has been registered, `get_state_manager()` (the dependency behind
+`StateManagerDep`) lazily creates a default `StateManager` on first use, backed by
+`BackendProxy`'s backend, and registers it. It does not fall back to a `MemoryBackend`:
+if no backend has been set, the request fails with `BackendNotFoundError`.
-## 例外
+## Exceptions
```
CacheXError
└── StateError
- ├── InvalidStateError # 不存在或已被消費
- ├── StateExpiredError # 已過期
- └── StateDataError # 內容格式不正確
+ ├── InvalidStateError # missing or already consumed
+ ├── StateExpiredError # expired
+ └── StateDataError # malformed content
```
-## 注意事項
-
-- **後端必須是跨行程共享的**。多 worker 部署時用 Redis 或 Memcached;`MemoryBackend`
- 的 state 只存在於產生它的那個行程,授權 callback 打到別的 worker 就會失敗。
-- **一次性保證來自後端的原子操作**:`get_and_delete()` 在 Redis 是 `GETDEL`、
- Memcached 是 get + `delete(noreply=False)` 的勝者判定、記憶體後端則在鎖內 pop。
- 自訂後端若只實作抽象方法,會落到 `BaseCacheBackend` 的非原子回退版本,
- 並行重播就有機會兩邊都成功 —— 請自行覆寫。
-- state 不該存放敏感資料;`metadata` 會以 JSON 明文存在快取後端。
+## Notes
+
+- **The backend must be shared across processes.** For multi-worker deployments use Redis or
+ Memcached. With `MemoryBackend` a state only exists in the process that created it, so an
+ authorization callback that lands on a different worker fails.
+- **The one-time guarantee comes from the backend's atomic operation.** `get_and_delete()` is
+ `GETDEL` on Redis (requires Redis server 6.2 or newer), a get followed by
+ `delete(noreply=False)` where only the caller whose delete succeeded wins on Memcached,
+ and a `pop` under the lock on the memory backend.
+ A custom backend that implements only the abstract methods falls back to
+ `BaseCacheBackend`'s non-atomic version, so a concurrent replay could succeed on both
+ sides. Override `get_and_delete()` in that case.
+- Do not store sensitive data in a state. `metadata` is stored as plain-text JSON in the
+ cache backend.
diff --git a/pyproject.toml b/pyproject.toml
index 5c8a387..2fa4834 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -60,6 +60,7 @@ docs = [
# Pre-1.0: upgrades are deliberate, not automatic.
"zensical>=0.0.65,<0.1",
"mkdocstrings-python>=2.0",
+ "markdown-callouts>=0.4",
]
[project.optional-dependencies]
diff --git a/uv.lock b/uv.lock
index 5ef14aa..400f708 100644
--- a/uv.lock
+++ b/uv.lock
@@ -554,6 +554,7 @@ dev = [
{ name = "types-redis" },
]
docs = [
+ { name = "markdown-callouts" },
{ name = "mkdocstrings-python" },
{ name = "zensical" },
]
@@ -591,6 +592,7 @@ dev = [
{ name = "types-redis", specifier = ">=4.6.0.20241004" },
]
docs = [
+ { name = "markdown-callouts", specifier = ">=0.4" },
{ name = "mkdocstrings-python", specifier = ">=2.0" },
{ name = "zensical", specifier = ">=0.0.65,<0.1" },
]
@@ -941,6 +943,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" },
]
+[[package]]
+name = "markdown-callouts"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/87/73/ae5aa379f6f7fea9d0bf4cba888f9a31d451d90f80033ae60ae3045770d5/markdown_callouts-0.4.0.tar.gz", hash = "sha256:7ed2c90486967058a73a547781121983839522d67041ae52c4979616f1b2b746", size = 9768, upload-time = "2024-01-22T23:18:18.513Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/b5/7b0a0a52c82bfccd830af2a8cc8add1c5bc932e0204922434954a631dd51/markdown_callouts-0.4.0-py3-none-any.whl", hash = "sha256:ed0da38f29158d93116a0d0c6ecaf9df90b37e0d989b5337d678ee6e6d6550b7", size = 7108, upload-time = "2024-01-22T23:18:17.465Z" },
+]
+
[[package]]
name = "markupsafe"
version = "3.0.3"
diff --git a/zensical.toml b/zensical.toml
index d3b24d5..64e391f 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -37,7 +37,6 @@ nav = [
{ "Contributing" = "CONTRIBUTING.md" },
] },
{ "Changelog" = "changelog.md" },
- { "繁體中文" = "README.zh-TW.md" },
]
[project.theme]
@@ -76,6 +75,8 @@ toggle.name = "Switch to system preference"
[project.markdown_extensions]
admonition = {}
+# GitHub-style `> [!NOTE]` callouts, so one syntax renders on GitHub and here.
+github-callouts = {}
attr_list = {}
def_list = {}
footnotes = {}