fastapi-cachex 0.3.4__tar.gz → 0.3.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/PKG-INFO +212 -11
  2. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/README.md +210 -8
  3. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/__init__.py +19 -0
  4. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/base.py +40 -2
  5. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/codec.py +12 -2
  6. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/memcached.py +49 -6
  7. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/memory.py +16 -12
  8. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/redis.py +2 -0
  9. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/cache.py +230 -16
  10. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/dependencies.py +13 -1
  11. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/proxy.py +2 -2
  12. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/config.py +43 -1
  13. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/manager.py +1 -1
  14. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/middleware.py +48 -46
  15. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/security.py +9 -2
  16. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/token_serializers.py +4 -4
  17. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/types.py +12 -1
  18. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/pyproject.toml +8 -4
  19. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/pyproject.toml.orig +5 -4
  20. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/__init__.py +0 -0
  21. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/config.py +0 -0
  22. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/directives.py +0 -0
  23. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/exceptions.py +0 -0
  24. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/manager.py +0 -0
  25. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/manager_proxy.py +0 -0
  26. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/py.typed +0 -0
  27. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/routes.py +0 -0
  28. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/__init__.py +0 -0
  29. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/dependencies.py +0 -0
  30. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/exceptions.py +0 -0
  31. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/models.py +0 -0
  32. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/proxy.py +0 -0
  33. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/__init__.py +0 -0
  34. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/dependencies.py +0 -0
  35. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/exceptions.py +0 -0
  36. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/manager.py +0 -0
  37. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/models.py +0 -0
  38. {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/proxy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-cachex
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
5
5
  Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
6
6
  Author: allen0099
@@ -20,12 +20,12 @@ Classifier: Framework :: FastAPI
20
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
21
  Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
22
22
  Requires-Dist: fastapi
23
+ Requires-Dist: itsdangerous
23
24
  Requires-Dist: pydantic
24
25
  Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
25
26
  Requires-Dist: pymemcache ; extra == 'memcache'
26
27
  Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
27
28
  Requires-Dist: orjson ; extra == 'redis'
28
- Requires-Dist: itsdangerous ; extra == 'starlette'
29
29
  Requires-Python: >=3.10
30
30
  Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
31
31
  Project-URL: Repository, https://github.com/allen0099/FastAPI-CacheX.git
@@ -33,7 +33,6 @@ Project-URL: Issues, https://github.com/allen0099/FastAPI-CacheX/issues
33
33
  Provides-Extra: jwt
34
34
  Provides-Extra: memcache
35
35
  Provides-Extra: redis
36
- Provides-Extra: starlette
37
36
  Description-Content-Type: text/markdown
38
37
 
39
38
  # FastAPI-Cache X
@@ -72,7 +71,9 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
72
71
  - Secure session management with HMAC-SHA256 token signing
73
72
  - Optional JWT token format for interoperability (install extra `jwt`)
74
73
  - IP address and User-Agent binding (optional security features)
75
- - Header and bearer token support (API-first architecture)
74
+ - Header, bearer token and cookie transports (cookies via
75
+ `FastAPICacheXSessionMiddleware`; the older `SessionMiddleware` is deprecated
76
+ and removed in 0.4.0 — see [Session Management Guide](docs/SESSION.md))
76
77
  - Automatic session renewal (sliding expiration)
77
78
  - Flash messages for cross-request communication
78
79
  - Multiple backend support (Redis, Memcached, In-Memory)
@@ -102,11 +103,19 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
102
103
  uv add fastapi-cachex
103
104
  ```
104
105
 
105
- To enable JWT token format support for sessions:
106
+ Everything in the core package works with the in-memory backend. The other
107
+ backends and the optional session transports ship as extras:
106
108
 
107
- ```bash
108
- uv add "fastapi-cachex[jwt]"
109
- ```
109
+ | Extra | Install | Pulls in | Needed for |
110
+ |-------|---------|----------|------------|
111
+ | `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
112
+ | `memcache` | `uv add "fastapi-cachex[memcache]"` | `pymemcache` | `MemcachedBackend` (note: `memcache`, not `memcached`) |
113
+ | `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
114
+
115
+ The `starlette` extra is gone: `itsdangerous` is a base dependency now, so
116
+ `FastAPICacheXSessionMiddleware` works on a plain install.
117
+
118
+ Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
110
119
 
111
120
  ### Development Installation
112
121
 
@@ -145,9 +154,88 @@ async def non_store_endpoint():
145
154
  @app.get("/clear_cache")
146
155
  async def remove_cache(cache: CacheBackend):
147
156
  await cache.clear_path("/path/to/clear") # Clear cache for a specific path
148
- await cache.clear_pattern("/path/to/clear/*") # Clear cache for a specific pattern
157
+ # Patterns match the whole key, not just the path
158
+ await cache.clear_pattern("GET|||*|||/path/to/clear/*")
149
159
  ```
150
160
 
161
+ `clear_pattern` globs the whole logical key. HTTP cache keys look like
162
+ `method|||host|||path|||query`, so a pattern that is only a path matches
163
+ nothing — use `clear_path(path, include_params=True)` when the path is what you
164
+ mean, and keep `clear_pattern` for keys you built yourself.
165
+
166
+ ### Invalidating a Single Cached Route
167
+
168
+ `clear_path`/`clear_pattern` work on ranges of keys. To drop exactly the entry a
169
+ `@cache`-decorated route would use — typically right after a mutation — call
170
+ `invalidate()`, which rebuilds that route's key with the same key builder and
171
+ deletes it:
172
+
173
+ ```python
174
+ from fastapi import Request
175
+ from starlette.requests import Request as StarletteRequest
176
+
177
+ from fastapi_cachex import cache, invalidate
178
+
179
+
180
+ @app.get("/items/{item_id}")
181
+ @cache(ttl=300)
182
+ async def read_item(item_id: int):
183
+ return await load(item_id)
184
+
185
+
186
+ @app.post("/items/{item_id}")
187
+ async def update_item(item_id: int, request: Request):
188
+ await save(item_id)
189
+ # Build the key the cached GET would have used: same host and headers,
190
+ # GET method, the cached path, no query string.
191
+ scope = dict(request.scope)
192
+ scope["method"] = "GET"
193
+ scope["path"] = f"/items/{item_id}"
194
+ scope["query_string"] = b""
195
+ return {"invalidated": await invalidate(StarletteRequest(scope))}
196
+ ```
197
+
198
+ `invalidate(request, key_builder=None)` returns `True` when an entry existed and
199
+ was removed, `False` otherwise (including when no backend is configured — it
200
+ never raises). The request you hand it must produce the cached route's key:
201
+ same method, host, path and query string. If the cached route uses a custom
202
+ `key_builder`, pass the same one here, or the key will not match.
203
+
204
+ ### Cache Monitoring Routes
205
+
206
+ `add_routes()` mounts two read-only endpoints that report what is currently in
207
+ the backend:
208
+
209
+ ```python
210
+ from fastapi import Depends, FastAPI
211
+ from fastapi_cachex import add_routes
212
+
213
+ app = FastAPI()
214
+ add_routes(
215
+ app,
216
+ prefix="/admin/cache", # default "" -> /cached-hits, /cached-records
217
+ include_in_schema=False, # default: hidden from OpenAPI
218
+ dependencies=[Depends(verify_admin)],
219
+ )
220
+ ```
221
+
222
+ - `GET {prefix}/cached-hits` — per-route hit counts and cache key information.
223
+ - `GET {prefix}/cached-records` — every cached record with its size, expiry and
224
+ a preview of the cached content.
225
+
226
+ > [!WARNING]
227
+ > **These routes have no authentication of their own.** `include_in_schema=False`
228
+ > only hides them from the OpenAPI document; anyone who guesses the path can read
229
+ > them. `/cached-records` includes a preview of the cached content and exposes
230
+ > your whole route structure. In production always pass
231
+ > `dependencies=[Depends(your_auth)]`, or mount them on an internal-only app.
232
+
233
+ > [!NOTE]
234
+ > The `ttl_remaining` field is not available on the Redis backend.
235
+ > `AsyncRedisCacheBackend.get_cache_data()` does not issue a per-key `TTL`
236
+ > lookup, so Redis-backed entries are reported as never expiring. Expiry itself
237
+ > still happens — only the monitoring view is blind to it.
238
+
151
239
  ### Application-Level Caching (Manual Get/Set)
152
240
 
153
241
  Beyond HTTP response caching via `@cache`, you can cache arbitrary JSON-serializable
@@ -157,6 +245,7 @@ namespaced wrapper around whichever backend is configured via `BackendProxy`.
157
245
  ```python
158
246
  from fastapi_cachex import AppCache, CacheManager
159
247
 
248
+
160
249
  @app.get("/expensive")
161
250
  async def expensive_operation(cache: AppCache):
162
251
  result = await cache.get("expensive:result")
@@ -172,9 +261,18 @@ await manager.set("user:42", {"name": "Alice"})
172
261
  user = await manager.get("user:42") # {"name": "Alice"}
173
262
  await manager.delete("user:42")
174
263
  await manager.clear_prefix() # clear everything under "myapp:"
264
+
265
+ # Compute-on-miss: `factory` runs only when the key is missing, expired, or
266
+ # undecodable. It may be sync or async.
267
+ profile = await manager.get_or_set("user:42", lambda: load_user(42), ttl=300)
268
+
269
+ # Glob over this manager's namespace, using the backend's native pattern
270
+ # support (Redis SCAN) rather than enumerating every key.
271
+ await manager.clear_pattern("user:*") # matches "myapp:user:*"
175
272
  ```
176
273
 
177
- `CacheManager.get()` returns `None` (or a supplied `default=`) on a cache miss —
274
+ `get_or_set()` provides no stampede protection: concurrent misses for the same
275
+ key each run `factory`. `CacheManager.get()` returns `None` (or a supplied `default=`) on a cache miss —
178
276
  it never raises for missing or corrupted entries. `CacheManager` keys live under
179
277
  their own `cache:`-prefixed namespace by default, separate from the HTTP route
180
278
  cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache
@@ -204,19 +302,91 @@ This ensures that:
204
302
  - Different query parameters get separate cache entries
205
303
  - The same endpoint with different parameters can be cached independently
206
304
 
305
+ Query parameters are taken in the order the client sent them, without sorting, so
306
+ `?a=1&b=2` and `?b=2&a=1` are two distinct cache entries for the same logical request.
307
+
207
308
  All backends automatically namespace keys with a prefix (e.g., `fastapi_cachex:`) to avoid conflicts with other applications.
208
309
 
209
310
  `CacheManager` (see [Application-Level Caching](#application-level-caching-manual-getset)) uses a separate, simpler `cache:`-prefixed key namespace instead of this `|||`-separated format, since its keys aren't tied to HTTP requests.
210
311
 
312
+ > [!WARNING]
313
+ > **The default cache key carries no user identity.** The backend is shared by
314
+ > every worker and every caller, so caching an authenticated endpoint with the
315
+ > default key builder will serve one user's response to the next user who hits
316
+ > the same path.
317
+ >
318
+ > For any endpoint whose response depends on who is asking, do one of:
319
+ >
320
+ > 1. **`private=True`** — the response is never read from or written to the
321
+ > shared backend. `Cache-Control: private` still lets the user's own browser
322
+ > cache it, and `If-None-Match` revalidation still works against freshly
323
+ > rendered content.
324
+ > 2. **A key builder that includes the caller's identity** — use this when you
325
+ > do want a server-side cache per user.
326
+
327
+ ```python
328
+ from fastapi_cachex import cache
329
+ from fastapi_cachex.types import CACHE_KEY_SEPARATOR
330
+
331
+
332
+ # 1. Keep it out of the shared cache entirely.
333
+ @app.get("/me/profile")
334
+ @cache(ttl=60, private=True)
335
+ async def my_profile(user: CurrentUser):
336
+ return user.profile
337
+
338
+
339
+ # 2. Or give each user their own entry.
340
+ def per_user_key(request: Request) -> str:
341
+ # `request.state.user_id` is populated by your authentication layer after
342
+ # it has verified the caller — never read the identity straight off an
343
+ # unverified request header (see the note below).
344
+ user_id = getattr(request.state, "user_id", "anonymous")
345
+ return (
346
+ f"{request.method}{CACHE_KEY_SEPARATOR}"
347
+ f"{request.headers.get('host', 'unknown')}{CACHE_KEY_SEPARATOR}"
348
+ f"{request.url.path}{CACHE_KEY_SEPARATOR}"
349
+ f"{request.query_params}{CACHE_KEY_SEPARATOR}{user_id}"
350
+ )
351
+
352
+
353
+ @app.get("/me/dashboard")
354
+ @cache(ttl=60, private=True, key_builder=per_user_key)
355
+ async def my_dashboard(user: CurrentUser):
356
+ return build_dashboard(user)
357
+ ```
358
+
359
+ > [!CAUTION]
360
+ > The key builder decides who sees whose data, so the identity it reads must
361
+ > come from something already verified — a claim from a checked token, a user
362
+ > your dependency resolved, or a value your auth middleware wrote to
363
+ > `request.state`.
364
+ >
365
+ > ```python
366
+ > # ❌ Never do this: anyone can send this header.
367
+ > user_id = request.headers.get("x-user-id", "anonymous")
368
+ > ```
369
+ >
370
+ > A key built from a raw request header is a horizontal privilege escalation:
371
+ > sending `X-User-Id: <someone-else>` returns that user's cached response.
372
+
211
373
  ### Cache Hit Behavior
212
374
 
213
375
  When a cached entry is valid (within TTL):
214
- - **Default behavior**: Returns the cached content with HTTP 200 status code directly without re-executing the endpoint handler
376
+ - **Default behavior**: Returns the cached content directly, with the status code and headers the handler originally produced, without re-executing the endpoint handler
215
377
  - **With `If-None-Match` header**: Returns HTTP 304 Not Modified if the ETag matches
216
378
  - **With `no-cache` directive**: Forces revalidation with fresh content before deciding on 304
379
+ - **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
217
380
 
218
381
  This means **cached hits are extremely fast** - the endpoint handler function is never executed.
219
382
 
383
+ Only successful responses are stored. A response the handler *returns* with a
384
+ non-2xx status (for example `Response(..., status_code=404)`) is passed straight
385
+ through and never cached, so a transient error cannot replace or poison the last
386
+ good entry. `206 Partial Content` is excluded as well, since its body is only
387
+ meaningful for the `Range` request that produced it. `Set-Cookie` is never
388
+ stored or replayed.
389
+
220
390
  ### Atomic backend primitives
221
391
 
222
392
  Every backend exposes two atomic operations on top of `get`/`set`/`delete`, for
@@ -317,6 +487,33 @@ backend = AsyncRedisCacheBackend(
317
487
  BackendProxy.set(backend)
318
488
  ```
319
489
 
490
+ **Configuring from a model**: `RedisConfig` is a pydantic model with the same
491
+ settings and validation, which is handy when they come from environment
492
+ variables or a settings file:
493
+
494
+ ```python
495
+ from fastapi_cachex.backends import AsyncRedisCacheBackend
496
+ from fastapi_cachex.backends.config import RedisConfig
497
+
498
+ config = RedisConfig(
499
+ host="127.0.0.1",
500
+ port=6379,
501
+ password=None, # SecretStr | None
502
+ db=0,
503
+ encoding="utf-8", # how the client decodes server responses
504
+ socket_timeout=1.0, # seconds; applies to reads/writes
505
+ socket_connect_timeout=1.0,
506
+ key_prefix="fastapi_cachex:",
507
+ protocol=2, # RESP version, 2 or 3
508
+ )
509
+ backend = AsyncRedisCacheBackend.load_from_config(config)
510
+ BackendProxy.set(backend)
511
+ ```
512
+
513
+ Keep `protocol=2` unless you need RESP3 features *and* your `hiredis` build
514
+ supports it (RESP3 needs hiredis >= 3.0). Redis 8.0 speaks RESP3, but an older
515
+ hiredis will fail to negotiate it.
516
+
320
517
  ## Performance Considerations
321
518
 
322
519
  ### Cache Hit Performance
@@ -343,8 +540,12 @@ async def expensive_operation():
343
540
 
344
541
  - [Cache Flow Explanation](docs/CACHE_FLOW.md)
345
542
  - [Development Guide](docs/DEVELOPMENT.md)
543
+ - [Known Limitations and Planned Work](docs/BACKLOG.md)
544
+ - [Changelog](CHANGELOG.md)
346
545
  - [Contributing Guidelines](docs/CONTRIBUTING.md)
347
546
  - [Session Management Guide](docs/SESSION.md) - Complete guide for session features
547
+ - [State Management Guide](docs/STATE.md) - One-shot OAuth/CSRF state tokens
548
+ - [JWT Claims Guide](docs/JWT_CLAIMS.md) - Claim design and extension points for JWT session tokens
348
549
 
349
550
  ## License
350
551
 
@@ -34,7 +34,9 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
34
34
  - Secure session management with HMAC-SHA256 token signing
35
35
  - Optional JWT token format for interoperability (install extra `jwt`)
36
36
  - IP address and User-Agent binding (optional security features)
37
- - Header and bearer token support (API-first architecture)
37
+ - Header, bearer token and cookie transports (cookies via
38
+ `FastAPICacheXSessionMiddleware`; the older `SessionMiddleware` is deprecated
39
+ and removed in 0.4.0 — see [Session Management Guide](docs/SESSION.md))
38
40
  - Automatic session renewal (sliding expiration)
39
41
  - Flash messages for cross-request communication
40
42
  - Multiple backend support (Redis, Memcached, In-Memory)
@@ -64,11 +66,19 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
64
66
  uv add fastapi-cachex
65
67
  ```
66
68
 
67
- To enable JWT token format support for sessions:
69
+ Everything in the core package works with the in-memory backend. The other
70
+ backends and the optional session transports ship as extras:
68
71
 
69
- ```bash
70
- uv add "fastapi-cachex[jwt]"
71
- ```
72
+ | Extra | Install | Pulls in | Needed for |
73
+ |-------|---------|----------|------------|
74
+ | `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
75
+ | `memcache` | `uv add "fastapi-cachex[memcache]"` | `pymemcache` | `MemcachedBackend` (note: `memcache`, not `memcached`) |
76
+ | `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
77
+
78
+ The `starlette` extra is gone: `itsdangerous` is a base dependency now, so
79
+ `FastAPICacheXSessionMiddleware` works on a plain install.
80
+
81
+ Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
72
82
 
73
83
  ### Development Installation
74
84
 
@@ -107,9 +117,88 @@ async def non_store_endpoint():
107
117
  @app.get("/clear_cache")
108
118
  async def remove_cache(cache: CacheBackend):
109
119
  await cache.clear_path("/path/to/clear") # Clear cache for a specific path
110
- await cache.clear_pattern("/path/to/clear/*") # Clear cache for a specific pattern
120
+ # Patterns match the whole key, not just the path
121
+ await cache.clear_pattern("GET|||*|||/path/to/clear/*")
111
122
  ```
112
123
 
124
+ `clear_pattern` globs the whole logical key. HTTP cache keys look like
125
+ `method|||host|||path|||query`, so a pattern that is only a path matches
126
+ nothing — use `clear_path(path, include_params=True)` when the path is what you
127
+ mean, and keep `clear_pattern` for keys you built yourself.
128
+
129
+ ### Invalidating a Single Cached Route
130
+
131
+ `clear_path`/`clear_pattern` work on ranges of keys. To drop exactly the entry a
132
+ `@cache`-decorated route would use — typically right after a mutation — call
133
+ `invalidate()`, which rebuilds that route's key with the same key builder and
134
+ deletes it:
135
+
136
+ ```python
137
+ from fastapi import Request
138
+ from starlette.requests import Request as StarletteRequest
139
+
140
+ from fastapi_cachex import cache, invalidate
141
+
142
+
143
+ @app.get("/items/{item_id}")
144
+ @cache(ttl=300)
145
+ async def read_item(item_id: int):
146
+ return await load(item_id)
147
+
148
+
149
+ @app.post("/items/{item_id}")
150
+ async def update_item(item_id: int, request: Request):
151
+ await save(item_id)
152
+ # Build the key the cached GET would have used: same host and headers,
153
+ # GET method, the cached path, no query string.
154
+ scope = dict(request.scope)
155
+ scope["method"] = "GET"
156
+ scope["path"] = f"/items/{item_id}"
157
+ scope["query_string"] = b""
158
+ return {"invalidated": await invalidate(StarletteRequest(scope))}
159
+ ```
160
+
161
+ `invalidate(request, key_builder=None)` returns `True` when an entry existed and
162
+ was removed, `False` otherwise (including when no backend is configured — it
163
+ never raises). The request you hand it must produce the cached route's key:
164
+ same method, host, path and query string. If the cached route uses a custom
165
+ `key_builder`, pass the same one here, or the key will not match.
166
+
167
+ ### Cache Monitoring Routes
168
+
169
+ `add_routes()` mounts two read-only endpoints that report what is currently in
170
+ the backend:
171
+
172
+ ```python
173
+ from fastapi import Depends, FastAPI
174
+ from fastapi_cachex import add_routes
175
+
176
+ app = FastAPI()
177
+ add_routes(
178
+ app,
179
+ prefix="/admin/cache", # default "" -> /cached-hits, /cached-records
180
+ include_in_schema=False, # default: hidden from OpenAPI
181
+ dependencies=[Depends(verify_admin)],
182
+ )
183
+ ```
184
+
185
+ - `GET {prefix}/cached-hits` — per-route hit counts and cache key information.
186
+ - `GET {prefix}/cached-records` — every cached record with its size, expiry and
187
+ a preview of the cached content.
188
+
189
+ > [!WARNING]
190
+ > **These routes have no authentication of their own.** `include_in_schema=False`
191
+ > only hides them from the OpenAPI document; anyone who guesses the path can read
192
+ > them. `/cached-records` includes a preview of the cached content and exposes
193
+ > your whole route structure. In production always pass
194
+ > `dependencies=[Depends(your_auth)]`, or mount them on an internal-only app.
195
+
196
+ > [!NOTE]
197
+ > The `ttl_remaining` field is not available on the Redis backend.
198
+ > `AsyncRedisCacheBackend.get_cache_data()` does not issue a per-key `TTL`
199
+ > lookup, so Redis-backed entries are reported as never expiring. Expiry itself
200
+ > still happens — only the monitoring view is blind to it.
201
+
113
202
  ### Application-Level Caching (Manual Get/Set)
114
203
 
115
204
  Beyond HTTP response caching via `@cache`, you can cache arbitrary JSON-serializable
@@ -119,6 +208,7 @@ namespaced wrapper around whichever backend is configured via `BackendProxy`.
119
208
  ```python
120
209
  from fastapi_cachex import AppCache, CacheManager
121
210
 
211
+
122
212
  @app.get("/expensive")
123
213
  async def expensive_operation(cache: AppCache):
124
214
  result = await cache.get("expensive:result")
@@ -134,9 +224,18 @@ await manager.set("user:42", {"name": "Alice"})
134
224
  user = await manager.get("user:42") # {"name": "Alice"}
135
225
  await manager.delete("user:42")
136
226
  await manager.clear_prefix() # clear everything under "myapp:"
227
+
228
+ # Compute-on-miss: `factory` runs only when the key is missing, expired, or
229
+ # undecodable. It may be sync or async.
230
+ profile = await manager.get_or_set("user:42", lambda: load_user(42), ttl=300)
231
+
232
+ # Glob over this manager's namespace, using the backend's native pattern
233
+ # support (Redis SCAN) rather than enumerating every key.
234
+ await manager.clear_pattern("user:*") # matches "myapp:user:*"
137
235
  ```
138
236
 
139
- `CacheManager.get()` returns `None` (or a supplied `default=`) on a cache miss —
237
+ `get_or_set()` provides no stampede protection: concurrent misses for the same
238
+ key each run `factory`. `CacheManager.get()` returns `None` (or a supplied `default=`) on a cache miss —
140
239
  it never raises for missing or corrupted entries. `CacheManager` keys live under
141
240
  their own `cache:`-prefixed namespace by default, separate from the HTTP route
142
241
  cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache
@@ -166,19 +265,91 @@ This ensures that:
166
265
  - Different query parameters get separate cache entries
167
266
  - The same endpoint with different parameters can be cached independently
168
267
 
268
+ Query parameters are taken in the order the client sent them, without sorting, so
269
+ `?a=1&b=2` and `?b=2&a=1` are two distinct cache entries for the same logical request.
270
+
169
271
  All backends automatically namespace keys with a prefix (e.g., `fastapi_cachex:`) to avoid conflicts with other applications.
170
272
 
171
273
  `CacheManager` (see [Application-Level Caching](#application-level-caching-manual-getset)) uses a separate, simpler `cache:`-prefixed key namespace instead of this `|||`-separated format, since its keys aren't tied to HTTP requests.
172
274
 
275
+ > [!WARNING]
276
+ > **The default cache key carries no user identity.** The backend is shared by
277
+ > every worker and every caller, so caching an authenticated endpoint with the
278
+ > default key builder will serve one user's response to the next user who hits
279
+ > the same path.
280
+ >
281
+ > For any endpoint whose response depends on who is asking, do one of:
282
+ >
283
+ > 1. **`private=True`** — the response is never read from or written to the
284
+ > shared backend. `Cache-Control: private` still lets the user's own browser
285
+ > cache it, and `If-None-Match` revalidation still works against freshly
286
+ > rendered content.
287
+ > 2. **A key builder that includes the caller's identity** — use this when you
288
+ > do want a server-side cache per user.
289
+
290
+ ```python
291
+ from fastapi_cachex import cache
292
+ from fastapi_cachex.types import CACHE_KEY_SEPARATOR
293
+
294
+
295
+ # 1. Keep it out of the shared cache entirely.
296
+ @app.get("/me/profile")
297
+ @cache(ttl=60, private=True)
298
+ async def my_profile(user: CurrentUser):
299
+ return user.profile
300
+
301
+
302
+ # 2. Or give each user their own entry.
303
+ def per_user_key(request: Request) -> str:
304
+ # `request.state.user_id` is populated by your authentication layer after
305
+ # it has verified the caller — never read the identity straight off an
306
+ # unverified request header (see the note below).
307
+ user_id = getattr(request.state, "user_id", "anonymous")
308
+ return (
309
+ f"{request.method}{CACHE_KEY_SEPARATOR}"
310
+ f"{request.headers.get('host', 'unknown')}{CACHE_KEY_SEPARATOR}"
311
+ f"{request.url.path}{CACHE_KEY_SEPARATOR}"
312
+ f"{request.query_params}{CACHE_KEY_SEPARATOR}{user_id}"
313
+ )
314
+
315
+
316
+ @app.get("/me/dashboard")
317
+ @cache(ttl=60, private=True, key_builder=per_user_key)
318
+ async def my_dashboard(user: CurrentUser):
319
+ return build_dashboard(user)
320
+ ```
321
+
322
+ > [!CAUTION]
323
+ > The key builder decides who sees whose data, so the identity it reads must
324
+ > come from something already verified — a claim from a checked token, a user
325
+ > your dependency resolved, or a value your auth middleware wrote to
326
+ > `request.state`.
327
+ >
328
+ > ```python
329
+ > # ❌ Never do this: anyone can send this header.
330
+ > user_id = request.headers.get("x-user-id", "anonymous")
331
+ > ```
332
+ >
333
+ > A key built from a raw request header is a horizontal privilege escalation:
334
+ > sending `X-User-Id: <someone-else>` returns that user's cached response.
335
+
173
336
  ### Cache Hit Behavior
174
337
 
175
338
  When a cached entry is valid (within TTL):
176
- - **Default behavior**: Returns the cached content with HTTP 200 status code directly without re-executing the endpoint handler
339
+ - **Default behavior**: Returns the cached content directly, with the status code and headers the handler originally produced, without re-executing the endpoint handler
177
340
  - **With `If-None-Match` header**: Returns HTTP 304 Not Modified if the ETag matches
178
341
  - **With `no-cache` directive**: Forces revalidation with fresh content before deciding on 304
342
+ - **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
179
343
 
180
344
  This means **cached hits are extremely fast** - the endpoint handler function is never executed.
181
345
 
346
+ Only successful responses are stored. A response the handler *returns* with a
347
+ non-2xx status (for example `Response(..., status_code=404)`) is passed straight
348
+ through and never cached, so a transient error cannot replace or poison the last
349
+ good entry. `206 Partial Content` is excluded as well, since its body is only
350
+ meaningful for the `Range` request that produced it. `Set-Cookie` is never
351
+ stored or replayed.
352
+
182
353
  ### Atomic backend primitives
183
354
 
184
355
  Every backend exposes two atomic operations on top of `get`/`set`/`delete`, for
@@ -279,6 +450,33 @@ backend = AsyncRedisCacheBackend(
279
450
  BackendProxy.set(backend)
280
451
  ```
281
452
 
453
+ **Configuring from a model**: `RedisConfig` is a pydantic model with the same
454
+ settings and validation, which is handy when they come from environment
455
+ variables or a settings file:
456
+
457
+ ```python
458
+ from fastapi_cachex.backends import AsyncRedisCacheBackend
459
+ from fastapi_cachex.backends.config import RedisConfig
460
+
461
+ config = RedisConfig(
462
+ host="127.0.0.1",
463
+ port=6379,
464
+ password=None, # SecretStr | None
465
+ db=0,
466
+ encoding="utf-8", # how the client decodes server responses
467
+ socket_timeout=1.0, # seconds; applies to reads/writes
468
+ socket_connect_timeout=1.0,
469
+ key_prefix="fastapi_cachex:",
470
+ protocol=2, # RESP version, 2 or 3
471
+ )
472
+ backend = AsyncRedisCacheBackend.load_from_config(config)
473
+ BackendProxy.set(backend)
474
+ ```
475
+
476
+ Keep `protocol=2` unless you need RESP3 features *and* your `hiredis` build
477
+ supports it (RESP3 needs hiredis >= 3.0). Redis 8.0 speaks RESP3, but an older
478
+ hiredis will fail to negotiate it.
479
+
282
480
  ## Performance Considerations
283
481
 
284
482
  ### Cache Hit Performance
@@ -305,8 +503,12 @@ async def expensive_operation():
305
503
 
306
504
  - [Cache Flow Explanation](docs/CACHE_FLOW.md)
307
505
  - [Development Guide](docs/DEVELOPMENT.md)
506
+ - [Known Limitations and Planned Work](docs/BACKLOG.md)
507
+ - [Changelog](CHANGELOG.md)
308
508
  - [Contributing Guidelines](docs/CONTRIBUTING.md)
309
509
  - [Session Management Guide](docs/SESSION.md) - Complete guide for session features
510
+ - [State Management Guide](docs/STATE.md) - One-shot OAuth/CSRF state tokens
511
+ - [JWT Claims Guide](docs/JWT_CLAIMS.md) - Claim design and extension points for JWT session tokens
310
512
 
311
513
  ## License
312
514
 
@@ -1,6 +1,8 @@
1
1
  """FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
2
2
 
3
3
  import logging
4
+ from importlib.metadata import PackageNotFoundError
5
+ from importlib.metadata import version
4
6
 
5
7
  from .cache import cache as cache
6
8
  from .cache import default_key_builder as default_key_builder
@@ -41,6 +43,22 @@ from .state import StateManagerProxy as StateManagerProxy
41
43
  from .state import get_state_manager as get_state_manager
42
44
  from .types import CacheKeyBuilder as CacheKeyBuilder
43
45
 
46
+
47
+ def _read_version() -> str:
48
+ """Return the installed distribution's version.
49
+
50
+ Importing from a source tree that was never installed leaves no metadata to
51
+ read; reporting a development version there is part of the contract, so this
52
+ lives in a function the tests can drive rather than behind a coverage pragma.
53
+ """
54
+ try:
55
+ return version("fastapi-cachex")
56
+ except PackageNotFoundError:
57
+ return "0.0.0.dev0"
58
+
59
+
60
+ __version__ = _read_version()
61
+
44
62
  _package_logger = logging.getLogger("fastapi_cachex")
45
63
  _package_logger.addHandler(
46
64
  logging.NullHandler()
@@ -74,6 +92,7 @@ __all__ = [
74
92
  "StateManager",
75
93
  "StateManagerDep",
76
94
  "StateManagerProxy",
95
+ "__version__",
77
96
  "add_routes",
78
97
  "cache",
79
98
  "default_key_builder",