diff --git a/README.md b/README.md index 6b4b50f..0f08982 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ async def report(cache: AppCache): - [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/) - [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) — one-shot OAuth/CSRF state tokens - [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion +- [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/) - [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/) - [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues) diff --git a/docs/APP_CACHE.md b/docs/APP_CACHE.md index eda3028..e756d94 100644 --- a/docs/APP_CACHE.md +++ b/docs/APP_CACHE.md @@ -37,6 +37,8 @@ if await manager.add(f"webhook:{event_id}", True, ttl=86400): await manager.clear_pattern("user:*") # matches "myapp:user:*" ``` +Complete runnable example: [`examples/app_cache.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/app_cache.py). + ## Behavior - `get()` returns `None` (or a supplied `default=`) on a cache miss — it never diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 45786da..0663dd6 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -120,6 +120,8 @@ Keep `protocol=2` unless you need RESP3 features *and* your `hiredis` build supports it (RESP3 needs hiredis >= 3.0). Redis 8.0 speaks RESP3, but an older hiredis will fail to negotiate it. +Complete runnable example: [`examples/redis_backend.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/redis_backend.py). + ## Memcached Install the extra with `uv add "fastapi-cachex[memcached]"`. Before 0.3.8 it was @@ -240,6 +242,8 @@ All five have a non-atomic fallback on `BaseCacheBackend`, so a third-party back that only implements the abstract methods keeps working; override them to get real atomicity. +Complete runnable example: [`examples/rate_limit.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/rate_limit.py). + ## TTL values Every `ttl` argument (`set`, `set_if_absent`, `increment`, and the `CacheManager` diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 989d685..ce5a8c3 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -4,6 +4,8 @@ The `@cache` decorator caches the responses of FastAPI GET routes and handles `Cache-Control`, `ETag` and `If-None-Match` for you. This page covers how to use it; [Cache flow](CACHE_FLOW.md) explains what happens inside a request. +Complete runnable example: [`examples/http_cache.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/http_cache.py). + ## The `@cache` decorator ```python diff --git a/docs/LOCK.md b/docs/LOCK.md index 1d2d2d9..2d24852 100644 --- a/docs/LOCK.md +++ b/docs/LOCK.md @@ -21,6 +21,8 @@ finally: await lock.release() ``` +Complete runnable example: [`examples/cache_lock.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/cache_lock.py). + ## Behavior - **Safety & Token Ownership**: Each `CacheLock` instance generates a unique token (`secrets.token_hex(16)`) stored inside a `CacheEntry`. Releases (`release()`) and extensions (`extend()`) use owner-checked backend primitives (`delete_if_equals` and `expire_if_equals`), so a holder whose lock expired cannot release or renew a lock claimed by someone else. diff --git a/docs/SESSION.md b/docs/SESSION.md index 1b68283..ab0b011 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -23,6 +23,8 @@ The six `cookie_*` settings of `SessionConfig` (`cookie_name`, `cookie_max_age`, `cookie_same_site`, `cookie_https_only`, `cookie_domain`) are **read only by `FastAPICacheXSessionMiddleware`**; setting them has no effect when `SessionMiddleware` is installed. +Complete runnable examples: [`examples/session_login.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_login.py) and [`examples/session_jwt.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt.py). + ## Features - ✅ **Session lifecycle management**: create, read, update, delete, invalidate diff --git a/docs/STATE.md b/docs/STATE.md index fa8ac79..cbc7902 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -19,6 +19,8 @@ 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. +Complete runnable example: [`examples/oauth_state.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/oauth_state.py). + ## Quick start ```python diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..7517fbc --- /dev/null +++ b/examples/README.md @@ -0,0 +1,56 @@ +# Examples + +Each file here is a complete FastAPI app that shows one feature of +FastAPI-CacheX. They use only the public API and the in-memory backend (except +`redis_backend.py`), so they run without any server. + +| Example | What it shows | Needs | +|---------|---------------|-------| +| [`http_cache.py`](http_cache.py) | `@cache` with a TTL, ETag and `304 Not Modified`, `no_cache` and `private` routes, `clear_path()` after an update, the monitoring routes behind an admin check | — | +| [`app_cache.py`](app_cache.py) | `CacheManager.get_or_set()` for an expensive call, `add()` as an idempotency check, the `AppCache` dependency | — | +| [`session_login.py`](session_login.py) | `FastAPICacheXSessionMiddleware` with cookies: an anonymous session, login with `rotate_session_id()`, `AuthenticatedSession`, logout with `request.session.clear()` | — | +| [`session_jwt.py`](session_jwt.py) | Sessions with `token_format="jwt"` for API clients (`Authorization: Bearer`), revoked on logout | `jwt` extra | +| [`oauth_state.py`](oauth_state.py) | One-time OAuth `state` values with `StateManager`, bound to the browser that started the flow | — | +| [`cache_lock.py`](cache_lock.py) | `CacheLock`, waiting (`async with`) and non-blocking (`acquire(blocking=False)`) | — | +| [`rate_limit.py`](rate_limit.py) | A fixed-window rate limiter on `backend.increment()`, answering `429` with `Retry-After` | — | +| [`redis_backend.py`](redis_backend.py) | `AsyncRedisCacheBackend` configured from environment variables in the lifespan and closed on shutdown | `redis` extra, a Redis server | + +## Running an example + +From a checkout of this repository: + +```bash +uv sync --group dev +uv run --with "fastapi-cli[standard]" fastapi dev examples/http_cache.py +``` + +Then open . The dev group already includes the +`jwt` and `redis` extras. `fastapi dev` comes from `fastapi-cli`, which is not +a dependency of this project, hence `--with`. Uvicorn works too: + +```bash +uv run --with uvicorn uvicorn examples.http_cache:app --reload +``` + +`redis_backend.py` reads `REDIS_HOST`, `REDIS_PORT`, `REDIS_DB` and +`REDIS_PASSWORD`; point it at a server you can write to. + +In your own project, install the extras an example needs, for example +`uv add "fastapi-cachex[jwt]"`, and copy the file. + +## Secrets + +The session examples read their signing key from `SESSION_SECRET_KEY` and fall +back to an obvious development placeholder. The monitoring routes in +`http_cache.py` stay closed until `CACHE_ADMIN_TOKEN` is set. Always set real, +random values outside local development: + +```bash +python -c "import secrets; print(secrets.token_urlsafe(48))" +``` + +## Tests + +`tests/test_examples.py` loads every example and drives its main flow, so the +examples keep working as the library changes. A new example needs a test +there and a row in the table above. diff --git a/examples/app_cache.py b/examples/app_cache.py new file mode 100644 index 0000000..1d5fbcf --- /dev/null +++ b/examples/app_cache.py @@ -0,0 +1,78 @@ +"""Caching your own values with ``CacheManager``. + +``get_or_set`` computes a value once and serves it from the cache until it +expires; ``add`` stores a key only if it is absent, which makes a simple +idempotency check. Both come through the ``AppCache`` dependency. + +Run it from a checkout (see ``examples/README.md``):: + + uv run --with "fastapi-cli[standard]" fastapi dev examples/app_cache.py +""" + +import secrets +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Annotated + +from fastapi import FastAPI +from fastapi import Header +from fastapi import HTTPException + +from fastapi_cachex import AppCache +from fastapi_cachex import BackendProxy +from fastapi_cachex import CacheManager +from fastapi_cachex import CacheManagerProxy +from fastapi_cachex.backends import MemoryBackend + +backend = MemoryBackend() +BackendProxy.set(backend) +# Optional: without this, AppCache creates a CacheManager with the defaults +# (key prefix "cache:", no TTL) on first use. +CacheManagerProxy.set(CacheManager(key_prefix="example:", default_ttl=300)) + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + """Stop the memory backend's cleanup task on shutdown.""" + yield + await backend.aclose() + + +app = FastAPI(lifespan=lifespan) + +# How often the "slow upstream" was really called. +upstream_calls: dict[str, int] = {"rates": 0} + + +async def fetch_rates() -> dict[str, float]: + """Stand-in for a slow call to another service.""" + upstream_calls["rates"] += 1 + return {"EUR": 0.92, "JPY": 151.3} + + +@app.get("/rates") +async def read_rates(app_cache: AppCache) -> dict[str, float]: + """Call the upstream once, then answer from the cache for 60 seconds.""" + rates: dict[str, float] = await app_cache.get_or_set("rates", fetch_rates, ttl=60) + return rates + + +@app.post("/orders", status_code=201) +async def create_order( + app_cache: AppCache, idempotency_key: Annotated[str, Header()] +) -> dict[str, str]: + """Accept each ``Idempotency-Key`` once; a retry gets ``409``.""" + order_id = secrets.token_hex(8) + # add() is atomic: of two concurrent retries exactly one gets True. + if not await app_cache.add(f"order:{idempotency_key}", order_id, ttl=3600): + existing = await app_cache.get(f"order:{idempotency_key}") + raise HTTPException( + status_code=409, detail=f"Already accepted as order {existing}" + ) + return {"order_id": order_id} + + +@app.delete("/rates") +async def forget_rates(app_cache: AppCache) -> dict[str, bool]: + """Drop the cached rates; the next read calls the upstream again.""" + return {"deleted": await app_cache.delete("rates")} diff --git a/examples/cache_lock.py b/examples/cache_lock.py new file mode 100644 index 0000000..2d97b06 --- /dev/null +++ b/examples/cache_lock.py @@ -0,0 +1,76 @@ +"""Mutual exclusion across workers with ``CacheLock``. + +``/reports/{name}/rebuild`` waits for the lock, so concurrent rebuilds of the +same report run one after the other. ``/imports`` does not wait: a second +import while one is running gets ``409``. With a shared backend such as Redis +this holds across processes and machines, not just within one. + +Run it from a checkout (see ``examples/README.md``):: + + uv run --with "fastapi-cli[standard]" fastapi dev examples/cache_lock.py +""" + +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi import HTTPException + +from fastapi_cachex import BackendProxy +from fastapi_cachex import CacheLock +from fastapi_cachex import LockTimeoutError +from fastapi_cachex.backends import MemoryBackend + +backend = MemoryBackend() +BackendProxy.set(backend) + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + """Stop the memory backend's cleanup task on shutdown.""" + yield + await backend.aclose() + + +app = FastAPI(lifespan=lifespan) + +# How many rebuilds run right now, and the most that ever ran at once. +rebuilds = {"running": 0, "max_running": 0} + + +async def rebuild_report() -> None: + """Stand-in for slow work that must not run twice at the same time.""" + rebuilds["running"] += 1 + rebuilds["max_running"] = max(rebuilds["max_running"], rebuilds["running"]) + try: + await asyncio.sleep(0.2) + finally: + rebuilds["running"] -= 1 + + +@app.post("/reports/{name}/rebuild") +async def rebuild(name: str) -> dict[str, str]: + """Wait up to 10 seconds for the lock, then rebuild.""" + try: + # ttl bounds how long a crashed holder keeps the lock; make it longer + # than the work, or call lock.extend() while working. + async with CacheLock(f"report:{name}", ttl=30, timeout=10): + await rebuild_report() + except LockTimeoutError as exc: + raise HTTPException(status_code=503, detail="Report is busy") from exc + return {"rebuilt": name} + + +@app.post("/imports") +async def start_import() -> dict[str, str]: + """Refuse at once when another import holds the lock.""" + # One CacheLock instance per acquisition. + lock = CacheLock("import", ttl=60) + if not await lock.acquire(blocking=False): + raise HTTPException(status_code=409, detail="An import is already running") + try: + await asyncio.sleep(0.2) # the import itself + finally: + await lock.release() + return {"imported": "ok"} diff --git a/examples/http_cache.py b/examples/http_cache.py new file mode 100644 index 0000000..e317df4 --- /dev/null +++ b/examples/http_cache.py @@ -0,0 +1,101 @@ +"""HTTP response caching with ``@cache``. + +Shows a TTL-cached route with ETag / ``304 Not Modified``, ``no_cache`` and +``private`` routes, clearing a path after an update, and the monitoring routes +behind an admin check. + +Run it from a checkout (see ``examples/README.md``):: + + uv run --with "fastapi-cli[standard]" fastapi dev examples/http_cache.py +""" + +import os +import secrets +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import Depends +from fastapi import FastAPI +from fastapi import Header +from fastapi import HTTPException + +from fastapi_cachex import BackendProxy +from fastapi_cachex import CacheBackend +from fastapi_cachex import add_routes +from fastapi_cachex import cache +from fastapi_cachex.backends import MemoryBackend + +# One backend per process; register it before the first request. +backend = MemoryBackend() +BackendProxy.set(backend) + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + """Stop the memory backend's cleanup task on shutdown.""" + yield + await backend.aclose() + + +app = FastAPI(lifespan=lifespan) + +PRODUCTS: dict[int, dict[str, object]] = { + 1: {"name": "Keyboard", "price": 49}, + 2: {"name": "Mouse", "price": 19}, +} + +# How often each handler really ran, so a cache hit is visible. +handler_runs: dict[str, int] = {"product": 0, "stock": 0} + + +@app.get("/products/{product_id}") +@cache(ttl=60) +async def read_product(product_id: int) -> dict[str, object]: + """Served from the cache for 60 seconds, with an ETag for revalidation.""" + handler_runs["product"] += 1 + if product_id not in PRODUCTS: + raise HTTPException(status_code=404, detail="Unknown product") + return {"id": product_id, **PRODUCTS[product_id]} + + +@app.put("/products/{product_id}") +async def update_product( + product_id: int, price: int, cache_backend: CacheBackend +) -> dict[str, object]: + """Change a price, then drop the cached copy so the next GET sees it.""" + if product_id not in PRODUCTS: + raise HTTPException(status_code=404, detail="Unknown product") + PRODUCTS[product_id]["price"] = price + await cache_backend.clear_path(f"/products/{product_id}") + return {"id": product_id, **PRODUCTS[product_id]} + + +@app.get("/stock/{product_id}") +@cache(ttl=60, no_cache=True) +async def read_stock(product_id: int) -> dict[str, int]: + """Sent as ``no-cache``: clients revalidate with ``If-None-Match`` each time.""" + handler_runs["stock"] += 1 + return {"id": product_id, "in_stock": 3} + + +@app.get("/me/preferences") +@cache(ttl=60, private=True) +async def my_preferences() -> dict[str, str]: + """Sent as ``private``: never stored in the shared backend.""" + # In a real app this depends on the verified caller. + return {"theme": "dark"} + + +def require_admin(x_admin_token: str | None = Header(default=None)) -> None: + """Allow the monitoring routes only with the token from ``CACHE_ADMIN_TOKEN``.""" + expected = os.environ.get("CACHE_ADMIN_TOKEN") + # With no token configured the routes stay closed. + if not expected or x_admin_token is None: + raise HTTPException(status_code=403, detail="Forbidden") + if not secrets.compare_digest(x_admin_token, expected): + raise HTTPException(status_code=403, detail="Forbidden") + + +# GET /_cache/cached-hits and /_cache/cached-records. They have no auth of +# their own, so always pass a dependency that has. +add_routes(app, prefix="/_cache", dependencies=[Depends(require_admin)]) diff --git a/examples/oauth_state.py b/examples/oauth_state.py new file mode 100644 index 0000000..0746e1c --- /dev/null +++ b/examples/oauth_state.py @@ -0,0 +1,79 @@ +"""One-time OAuth ``state`` values with ``StateManager``. + +``/login`` stores a random state bound to a nonce cookie and redirects to the +provider; ``/callback`` consumes it. A state works once, and only in the +browser that started the flow, which is what protects the callback against +CSRF (RFC 6749, section 10.12). + +The provider here is a placeholder: this app never talks to it. +Run it from a checkout (see ``examples/README.md``):: + + uv run --with "fastapi-cli[standard]" fastapi dev examples/oauth_state.py +""" + +import secrets +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from urllib.parse import urlencode + +from fastapi import FastAPI +from fastapi import HTTPException +from fastapi import Request +from fastapi.responses import RedirectResponse + +from fastapi_cachex import BackendProxy +from fastapi_cachex import StateManagerDep +from fastapi_cachex.backends import MemoryBackend +from fastapi_cachex.state import StateError + +backend = MemoryBackend() +BackendProxy.set(backend) + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + """Stop the memory backend's cleanup task on shutdown.""" + yield + await backend.aclose() + + +app = FastAPI(lifespan=lifespan) + +AUTHORIZE_URL = "https://provider.example.com/authorize" +BINDING_COOKIE = "oauth_binding" + + +@app.get("/login") +async def login(states: StateManagerDep) -> RedirectResponse: + """Create a state bound to this browser and send it to the provider.""" + nonce = secrets.token_urlsafe(32) + state = await states.create_state( + ttl=600, binding=nonce, metadata={"next": "/dashboard"} + ) + query = urlencode({"state": state, "client_id": "example-client"}) + response = RedirectResponse(f"{AUTHORIZE_URL}?{query}") + # Lax, not Strict: the callback is a cross-site navigation from the provider. + # Browsers accept Secure cookies on http://localhost. + response.set_cookie( + BINDING_COOKIE, nonce, max_age=600, httponly=True, secure=True, samesite="lax" + ) + return response + + +@app.get("/callback") +async def callback( + request: Request, state: str, code: str, states: StateManagerDep +) -> RedirectResponse: + """Accept the state once, and only from the browser that started the flow.""" + try: + data = await states.consume_state( + state, binding=request.cookies.get(BINDING_COOKIE) + ) + except StateError as exc: # unknown, expired, reused or another browser's + raise HTTPException(status_code=400, detail="Invalid state") from exc + + # Exchange `code` for tokens at the provider and create a session here. + _ = code + response = RedirectResponse(str(data.metadata.get("next", "/"))) + response.delete_cookie(BINDING_COOKIE) + return response diff --git a/examples/rate_limit.py b/examples/rate_limit.py new file mode 100644 index 0000000..4a9a3f7 --- /dev/null +++ b/examples/rate_limit.py @@ -0,0 +1,64 @@ +"""A fixed-window rate limiter on ``backend.increment``. + +Each client may call ``/search`` ``LIMIT`` times per ``WINDOW`` seconds; the +next call gets ``429 Too Many Requests`` with a ``Retry-After`` header. The +counter is one atomic ``increment`` per request, so concurrent requests (and, +with Redis, several workers) never lose a count. + +Run it from a checkout (see ``examples/README.md``):: + + uv run --with "fastapi-cli[standard]" fastapi dev examples/rate_limit.py +""" + +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import Depends +from fastapi import FastAPI +from fastapi import HTTPException +from fastapi import Request + +from fastapi_cachex import BackendProxy +from fastapi_cachex import CacheBackend +from fastapi_cachex.backends import MemoryBackend + +backend = MemoryBackend() +BackendProxy.set(backend) + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + """Stop the memory backend's cleanup task on shutdown.""" + yield + await backend.aclose() + + +app = FastAPI(lifespan=lifespan) + +LIMIT = 5 +WINDOW = 60 # seconds + + +async def rate_limit(request: Request, cache_backend: CacheBackend) -> None: + """Allow ``LIMIT`` requests per client per window, then answer ``429``.""" + # Behind a reverse proxy, request.client is the proxy: derive the client + # address from a header only your proxy can set. + client = request.client.host if request.client else "unknown" + window = int(time.time()) // WINDOW + # A new key per window; the TTL only applies when the counter is created, + # so it expires with its window and never needs deleting. + count = await cache_backend.increment(f"ratelimit:{client}:{window}", ttl=WINDOW) + if count > LIMIT: + retry_after = WINDOW - int(time.time()) % WINDOW + raise HTTPException( + status_code=429, + detail="Too many requests", + headers={"Retry-After": str(retry_after)}, + ) + + +@app.get("/search", dependencies=[Depends(rate_limit)]) +async def search(q: str) -> dict[str, list[str]]: + """A route worth protecting.""" + return {"results": [f"{q} result"]} diff --git a/examples/redis_backend.py b/examples/redis_backend.py new file mode 100644 index 0000000..3fcc8ab --- /dev/null +++ b/examples/redis_backend.py @@ -0,0 +1,61 @@ +"""Using Redis as the backend. + +The backend is built from environment variables when the app starts and its +connection pool is closed on shutdown. Every other example works the same +way on Redis: replace its ``MemoryBackend`` with this setup. + +Needs the ``redis`` extra (``uv add "fastapi-cachex[redis]"``) and a Redis +server. The settings are read from ``REDIS_HOST`` (default ``127.0.0.1``), +``REDIS_PORT`` (``6379``), ``REDIS_DB`` (``0``) and ``REDIS_PASSWORD`` +(unset). Run it from a checkout (see ``examples/README.md``):: + + REDIS_PORT=6379 uv run --with "fastapi-cli[standard]" fastapi dev examples/redis_backend.py +""" + +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from fastapi_cachex import BackendProxy +from fastapi_cachex import CacheBackend +from fastapi_cachex import cache +from fastapi_cachex.backends import AsyncRedisCacheBackend + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + """Connect to Redis on startup and close the connection pool on shutdown.""" + backend = AsyncRedisCacheBackend( + host=os.environ.get("REDIS_HOST", "127.0.0.1"), + port=int(os.environ.get("REDIS_PORT", "6379")), + db=int(os.environ.get("REDIS_DB", "0")), + password=os.environ.get("REDIS_PASSWORD") or None, + # Namespaces this app's keys on a shared server. + key_prefix="fastapi_cachex_example:", + ) + BackendProxy.set(backend) + try: + yield + finally: + BackendProxy.set(None) + # The types-redis stubs predate aclose() (redis-py 5.0.1+). + await backend.client.aclose() # type: ignore[attr-defined] + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/hello/{name}") +@cache(ttl=60) +async def hello(name: str) -> dict[str, str]: + """Cached in Redis, so every worker process shares the entry.""" + return {"hello": name} + + +@app.post("/visits") +async def count_visit(cache_backend: CacheBackend) -> dict[str, int]: + """A counter shared by every worker, incremented atomically on Redis.""" + visits = await cache_backend.increment("visits") + return {"visits": visits} diff --git a/examples/session_jwt.py b/examples/session_jwt.py new file mode 100644 index 0000000..6fd5de7 --- /dev/null +++ b/examples/session_jwt.py @@ -0,0 +1,100 @@ +"""Sessions carried as JWTs, for API clients. + +With ``token_format="jwt"`` the session token is a signed JWT. The session +itself still lives in the backend, so logging out revokes the token at once. +The client sends it as ``Authorization: Bearer ``. + +Needs the ``jwt`` extra: ``uv add "fastapi-cachex[jwt]"``. +Run it from a checkout (see ``examples/README.md``):: + + uv run --with "fastapi-cli[standard]" fastapi dev examples/session_jwt.py +""" + +import os +import secrets +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi import HTTPException +from fastapi import Request +from pydantic import BaseModel + +from fastapi_cachex import BackendProxy +from fastapi_cachex import FastAPICacheXSessionMiddleware +from fastapi_cachex import SessionConfig +from fastapi_cachex import SessionManager +from fastapi_cachex import SessionUser +from fastapi_cachex.backends import MemoryBackend +from fastapi_cachex.session.dependencies import AuthenticatedSession +from fastapi_cachex.session.dependencies import ClientIPDep + +backend = MemoryBackend() +BackendProxy.set(backend) + +config = SessionConfig( + # HS256 wants a key of at least 32 bytes (HS384: 48, HS512: 64), or + # SessionManager warns. Set a real random value in production, e.g. + # `python -c "import secrets; print(secrets.token_urlsafe(48))"`. + secret_key=os.environ.get( + "SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying" + ), + token_format="jwt", + jwt_algorithm="HS256", + # Optional: issued as `iss`/`aud` and checked on every request. + jwt_issuer="https://api.example.com", + jwt_audience="example-clients", +) +session_manager = SessionManager(backend, config) + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + """Stop the memory backend's cleanup task on shutdown.""" + yield + await backend.aclose() + + +app = FastAPI(lifespan=lifespan) +app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=session_manager, config=config +) + +# Demo accounts only. Store password hashes (argon2, bcrypt) in a real app. +DEMO_USERS = {"alice": "alice-demo-password"} + + +class Credentials(BaseModel): + """Login form.""" + + username: str + password: str + + +@app.post("/token") +async def issue_token( + credentials: Credentials, client_ip: ClientIPDep +) -> dict[str, str]: + """Check the password and return a bearer token for a new session.""" + expected = DEMO_USERS.get(credentials.username) + if expected is None or not secrets.compare_digest(credentials.password, expected): + raise HTTPException(status_code=401, detail="Wrong username or password") + _, token = await session_manager.create_session( + user=SessionUser(user_id=credentials.username), + ip_address=client_ip, + ) + return {"access_token": token, "token_type": "bearer"} + + +@app.get("/me") +async def me(session: AuthenticatedSession) -> dict[str, str]: + """Requires ``Authorization: Bearer `` for a session with a user.""" + assert session.user is not None # guaranteed by AuthenticatedSession # noqa: S101 + return {"user": session.user.user_id} + + +@app.post("/logout") +async def logout(request: Request) -> dict[str, bool]: + """Delete the session: the JWT stops working even before it expires.""" + request.session.clear() + return {"logged_out": True} diff --git a/examples/session_login.py b/examples/session_login.py new file mode 100644 index 0000000..8036ce5 --- /dev/null +++ b/examples/session_login.py @@ -0,0 +1,136 @@ +"""Server-side sessions with ``FastAPICacheXSessionMiddleware``. + +A visitor gets an anonymous session as soon as something is written to +``request.session`` (here, a shopping cart). Logging in rotates the session ID +against session fixation and attaches the user, keeping the cart; logging out +deletes the session. The token travels in a cookie, as with Starlette's +``SessionMiddleware``; header and ``Authorization: Bearer`` tokens work too. + +Run it from a checkout (see ``examples/README.md``):: + + uv run --with "fastapi-cli[standard]" fastapi dev examples/session_login.py +""" + +import os +import secrets +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi import HTTPException +from fastapi import Request +from fastapi import Response +from pydantic import BaseModel + +from fastapi_cachex import BackendProxy +from fastapi_cachex import FastAPICacheXSessionMiddleware +from fastapi_cachex import SessionConfig +from fastapi_cachex import SessionManager +from fastapi_cachex import SessionUser +from fastapi_cachex.backends import MemoryBackend +from fastapi_cachex.session import rotate_session_id +from fastapi_cachex.session.dependencies import AuthenticatedSession +from fastapi_cachex.session.dependencies import ClientIPDep +from fastapi_cachex.session.dependencies import OptionalSession + +backend = MemoryBackend() +BackendProxy.set(backend) + +config = SessionConfig( + # At least 32 characters. Set a real random value in production, e.g. + # `python -c "import secrets; print(secrets.token_urlsafe(48))"`. + secret_key=os.environ.get( + "SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying" + ), + session_ttl=3600, + # Keep False only for local HTTP development. + cookie_https_only=False, +) +session_manager = SessionManager(backend, config) + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + """Stop the memory backend's cleanup task on shutdown.""" + yield + await backend.aclose() + + +app = FastAPI(lifespan=lifespan) +app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=session_manager, config=config +) + +# Demo accounts only. Store password hashes (argon2, bcrypt) in a real app. +DEMO_USERS = {"alice": "alice-demo-password"} + + +class Credentials(BaseModel): + """Login form.""" + + username: str + password: str + + +@app.post("/cart/{item}") +async def add_to_cart(item: str, request: Request) -> dict[str, list[str]]: + """Writing to ``request.session`` starts an anonymous session if needed.""" + cart = [*request.session.get("cart", []), item] + request.session["cart"] = cart + return {"cart": cart} + + +@app.post("/login") +async def login( + credentials: Credentials, + request: Request, + response: Response, + client_ip: ClientIPDep, + session: OptionalSession, +) -> dict[str, str]: + """Check the password, then attach the user under a new session ID.""" + expected = DEMO_USERS.get(credentials.username) + if expected is None or not secrets.compare_digest(credentials.password, expected): + raise HTTPException(status_code=401, detail="Wrong username or password") + user = SessionUser(user_id=credentials.username, username=credentials.username) + + if session is not None: + # The visitor already has a session (their cart). Give it a new ID so a + # token planted before login is worthless, then attach the user. The + # middleware sends the new token in place of the old one. + await rotate_session_id(request) + session.user = user + await session_manager.update_session(session) + return {"user": user.user_id} + + # No session yet: create one for the user and set the cookie ourselves, + # since the middleware only sends tokens for sessions it loaded or created. + _, token = await session_manager.create_session( + user=user, + ip_address=client_ip, + user_agent=request.headers.get("user-agent"), + ) + response.set_cookie( + config.cookie_name, + token, + max_age=config.cookie_max_age, + path=config.cookie_path, + httponly=True, + samesite=config.cookie_same_site, + secure=config.cookie_https_only, + ) + return {"user": user.user_id} + + +@app.get("/me") +async def me(session: AuthenticatedSession) -> dict[str, object]: + """Only for logged-in users; an anonymous session gets ``401`` too.""" + assert session.user is not None # guaranteed by AuthenticatedSession # noqa: S101 + return {"user": session.user.user_id, "cart": session.data.get("cart", [])} + + +@app.post("/logout") +async def logout(request: Request) -> dict[str, bool]: + """``clear()`` deletes the session and expires the cookie.""" + request.session.clear() + return {"logged_out": True} diff --git a/i18n/zh-TW/docs/APP_CACHE.md b/i18n/zh-TW/docs/APP_CACHE.md index 1c3f7d4..b3f6c23 100644 --- a/i18n/zh-TW/docs/APP_CACHE.md +++ b/i18n/zh-TW/docs/APP_CACHE.md @@ -35,6 +35,8 @@ if await manager.add(f"webhook:{event_id}", True, ttl=86400): await manager.clear_pattern("user:*") # 比對 "myapp:user:*" ``` +完整可執行範例(英文):[`examples/app_cache.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/app_cache.py)。 + ## 行為 {#behavior} - `get()` 在快取未命中時回傳 `None`(或你提供的 `default=`),遇到不存在或損毀的項目也絕不會拋出例外。 diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 0889ed7..b4f0c06 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -91,6 +91,8 @@ BackendProxy.set(backend) 除非你需要 RESP3 的功能,*而且*你的 `hiredis` 建置支援它(RESP3 需要 hiredis >= 3.0),否則請保留 `protocol=2`。Redis 8.0 支援 RESP3,但較舊的 hiredis 會無法協商使用它。 +完整可執行範例(英文):[`examples/redis_backend.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/redis_backend.py)。 + ## Memcached {#memcached} 以 `uv add "fastapi-cachex[memcached]"` 安裝此 extra。0.3.8 以前這個 extra 名為 `memcache`;舊名稱仍可使用但已棄用,將於 0.4.0 移除。安裝時遇到不存在的 extra 只會顯示警告,因此 0.4.0 之後 `fastapi-cachex[memcache]` 會裝好套件但不含 `pymemcache`。 @@ -155,6 +157,8 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): 這五個方法在 `BaseCacheBackend` 上都有非原子性的後備實作,因此只實作抽象方法的第三方後端仍可正常運作;覆寫它們才能得到真正的原子性。 +完整可執行範例(英文):[`examples/rate_limit.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/rate_limit.py)。 + ## TTL 值 {#ttl-values} 每個 `ttl` 參數(`set`、`set_if_absent`、`increment`,以及建立在它們之上的 `CacheManager` 與 `StateManager` 方法和預設值)只能是 `None`(表示項目永不過期),或介於 1 到 `MAX_TTL`(2**31 - 1,約 68 年)之間的 `int` 秒數。這些檢查都在存取後端之前進行: diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 7921ba6..2a7c680 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -2,6 +2,8 @@ `@cache` 裝飾器會快取 FastAPI GET 路由的回應,並替你處理 `Cache-Control`、`ETag` 與 `If-None-Match`。本頁說明如何使用它;[快取流程](CACHE_FLOW.md)則說明請求內部發生了什麼。 +完整可執行範例(英文):[`examples/http_cache.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/http_cache.py)。 + ## `@cache` 裝飾器 {#the-cache-decorator} ```python diff --git a/i18n/zh-TW/docs/LOCK.md b/i18n/zh-TW/docs/LOCK.md index 54e6f08..4f9235d 100644 --- a/i18n/zh-TW/docs/LOCK.md +++ b/i18n/zh-TW/docs/LOCK.md @@ -21,6 +21,8 @@ finally: await lock.release() ``` +完整可執行範例(英文):[`examples/cache_lock.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/cache_lock.py)。 + ## 行為 {#behavior} - **安全性與權杖所有權**:每個 `CacheLock` 實例都會產生一個唯一的權杖(`secrets.token_hex(16)`),存放在 `CacheEntry` 中。釋放(`release()`)與續約(`extend()`)都使用會檢查持有者的後端原子操作(`delete_if_equals` 與 `expire_if_equals`),因此鎖已過期的持有者無法釋放或續約已被他人取得的鎖。 diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index 7c63f70..ef89787 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -13,6 +13,8 @@ FastAPI-CacheX 的 Session 管理提供完整的使用者 Session 處理,包 `SessionConfig` 的六個 `cookie_*` 設定(`cookie_name`、`cookie_max_age`、`cookie_path`、`cookie_same_site`、`cookie_https_only`、`cookie_domain`)**只有 `FastAPICacheXSessionMiddleware` 會讀取**;安裝的是 `SessionMiddleware` 時,設定它們不會有任何效果。 +完整可執行範例(英文):[`examples/session_login.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_login.py)、[`examples/session_jwt.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/session_jwt.py)。 + ## 功能特點 {#features} - ✅ **Session 生命週期管理**:建立、讀取、更新、刪除、使其失效 diff --git a/i18n/zh-TW/docs/STATE.md b/i18n/zh-TW/docs/STATE.md index 23be6f5..ccf10c9 100644 --- a/i18n/zh-TW/docs/STATE.md +++ b/i18n/zh-TW/docs/STATE.md @@ -8,6 +8,8 @@ State 與 HTTP 快取存放在同一個後端,但使用自己的鍵前綴( 本指南中的所有內容也都可以從頂層的 `fastapi_cachex` 套件匯入。 +完整可執行範例(英文):[`examples/oauth_state.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/oauth_state.py)。 + ## 快速開始 {#quick-start} ```python diff --git a/i18n/zh-TW/docs/index.md b/i18n/zh-TW/docs/index.md index f447781..9941e3a 100644 --- a/i18n/zh-TW/docs/index.md +++ b/i18n/zh-TW/docs/index.md @@ -82,6 +82,7 @@ async def report(cache: AppCache): - [Session 管理](SESSION.md)與 [JWT claims](JWT_CLAIMS.md) - [OAuth state](STATE.md):一次性的 OAuth / CSRF state 權杖 - [分散式鎖](LOCK.md):以 `CacheLock` 在多個行程之間互斥 +- [可執行範例](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples)(英文):每個功能一個完整的應用程式,皆由測試套件涵蓋 - [API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)(英文) - [開發指南](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/)(英文)與[貢獻指南](CONTRIBUTING.md) - [變更紀錄](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md)(英文) · [已知限制與規劃中的工作](https://github.com/allen0099/FastAPI-CacheX/issues) diff --git a/pyproject.toml b/pyproject.toml index 91edca5..2e93006 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,6 +115,10 @@ keep-runtime-typing = true [tool.ruff.lint.per-file-ignores] "scripts/**" = ["INP001"] # Standalone tooling, not an importable package +"examples/**" = [ + "INP001", # Standalone apps, run by path; not an importable package + "S106", # token_format="jwt" is not a password +] "tests/**" = [ "D", "S101", diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..9a1e1aa --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,359 @@ +"""Run every app in `examples/` through its main flow. + +The examples are documentation, so these tests keep them working: each one is +loaded fresh from its file, started with its lifespan, and driven through +`TestClient`. A `DeprecationWarning` fails the test, so an example cannot keep +showing an API we are phasing out. + +`session_jwt` and `redis_backend` need optional packages and are skipped +without them (checked with `find_spec`, never imported here). `redis_backend` +also talks to a real server, so it follows the opt-in rules in +`tests/live_servers.py`. +""" + +import importlib.util +import sys +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from types import ModuleType +from urllib.parse import parse_qs +from urllib.parse import urlsplit + +import pytest +from fastapi.testclient import TestClient + +from fastapi_cachex.exceptions import BackendNotFoundError +from fastapi_cachex.manager_proxy import CacheManagerProxy +from fastapi_cachex.proxy import BackendProxy +from fastapi_cachex.session.proxy import SessionManagerProxy +from fastapi_cachex.state.proxy import StateManagerProxy +from tests.live_servers import REDIS_HOST +from tests.live_servers import REDIS_PORT +from tests.live_servers import redis_skip_reason + +pytestmark = pytest.mark.filterwarnings("error::DeprecationWarning") + +EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "examples" +_PROXIES = (BackendProxy, CacheManagerProxy, SessionManagerProxy, StateManagerProxy) + + +@pytest.fixture(autouse=True) +def _reset_proxies() -> Iterator[None]: + """Every example registers its own backend and managers at import.""" + for proxy in _PROXIES: + proxy.set(None) + yield + for proxy in _PROXIES: + proxy.set(None) + + +def load_example(name: str) -> ModuleType: + """Import `examples/.py` as a new module, so no state is shared.""" + module_name = f"_cachex_example_{name}" + spec = importlib.util.spec_from_file_location( + module_name, EXAMPLES_DIR / f"{name}.py" + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + finally: + del sys.modules[module_name] + return module + + +def test_every_example_has_a_test() -> None: + """A new example must be added to this file (and to examples/README.md).""" + tested = { + "app_cache", + "cache_lock", + "http_cache", + "oauth_state", + "rate_limit", + "redis_backend", + "session_jwt", + "session_login", + } + assert {p.stem for p in EXAMPLES_DIR.glob("*.py")} == tested + readme = (EXAMPLES_DIR / "README.md").read_text(encoding="utf-8") + for name in tested: + assert f"{name}.py" in readme + + +def test_http_cache() -> None: + example = load_example("http_cache") + with TestClient(example.app) as client: + first = client.get("/products/1") + second = client.get("/products/1") + assert first.status_code == second.status_code == 200 + assert ( + first.json() + == second.json() + == { + "id": 1, + "name": "Keyboard", + "price": 49, + } + ) + # Miss, then hit: the handler ran once. + assert example.handler_runs["product"] == 1 + assert first.headers["cache-control"] == "max-age=60" + + etag = first.headers["etag"] + not_modified = client.get("/products/1", headers={"If-None-Match": etag}) + assert not_modified.status_code == 304 + assert example.handler_runs["product"] == 1 + + # The update clears the cached copy. + assert client.put("/products/1", params={"price": 59}).status_code == 200 + assert client.get("/products/1").json()["price"] == 59 + assert example.handler_runs["product"] == 2 + + stock = client.get("/stock/1") + assert stock.headers["cache-control"] == "no-cache" + prefs = client.get("/me/preferences") + assert "private" in prefs.headers["cache-control"] + + +def test_http_cache_monitoring_routes_need_the_admin_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + example = load_example("http_cache") + with TestClient(example.app) as client: + monkeypatch.delenv("CACHE_ADMIN_TOKEN", raising=False) + # Closed while no token is configured, whatever the client sends. + assert client.get("/_cache/cached-hits").status_code == 403 + assert ( + client.get("/_cache/cached-hits", headers={"X-Admin-Token": ""}).status_code + == 403 + ) + + monkeypatch.setenv("CACHE_ADMIN_TOKEN", "test-admin-token") + client.get("/products/2") + assert ( + client.get( + "/_cache/cached-hits", headers={"X-Admin-Token": "wrong"} + ).status_code + == 403 + ) + hits = client.get( + "/_cache/cached-hits", headers={"X-Admin-Token": "test-admin-token"} + ) + assert hits.status_code == 200 + assert "/products/2" in hits.text + + +def test_app_cache() -> None: + example = load_example("app_cache") + with TestClient(example.app) as client: + first = client.get("/rates") + second = client.get("/rates") + assert first.json() == second.json() == {"EUR": 0.92, "JPY": 151.3} + # The factory ran once for two reads. + assert example.upstream_calls["rates"] == 1 + + assert client.delete("/rates").json() == {"deleted": True} + client.get("/rates") + assert example.upstream_calls["rates"] == 2 + + created = client.post("/orders", headers={"Idempotency-Key": "abc"}) + assert created.status_code == 201 + order_id = created.json()["order_id"] + retry = client.post("/orders", headers={"Idempotency-Key": "abc"}) + assert retry.status_code == 409 + assert order_id in retry.json()["detail"] + other = client.post("/orders", headers={"Idempotency-Key": "def"}) + assert other.status_code == 201 + + +def _session_cookie(client: TestClient) -> str: + token = client.cookies.get("session") + assert token + return str(token) + + +def test_session_login() -> None: + example = load_example("session_login") + credentials = {"username": "alice", "password": "alice-demo-password"} + with TestClient(example.app) as client: + # An anonymous visitor fills a cart: that starts a session. + assert client.post("/cart/book").json() == {"cart": ["book"]} + anonymous_token = _session_cookie(client) + # An anonymous session is not a login. + assert client.get("/me").status_code == 401 + + wrong = {"username": "alice", "password": "nope"} + assert client.post("/login", json=wrong).status_code == 401 + + assert client.post("/login", json=credentials).status_code == 200 + user_token = _session_cookie(client) + # Login rotated the session ID: a new token, and the old one is dead. + assert user_token != anonymous_token + me = client.get("/me") + assert me.status_code == 200 + assert me.json() == {"user": "alice", "cart": ["book"]} + + with TestClient(example.app) as stranger: + assert stranger.get("/me").status_code == 401 + stranger.cookies.set("session", anonymous_token) + assert stranger.get("/me").status_code == 401 + stranger.cookies.clear() + # The header transport reaches the same session. + assert ( + stranger.get("/me", headers={"X-Session-Token": user_token}).status_code + == 200 + ) + + assert client.post("/logout").status_code == 200 + with TestClient(example.app) as replay: + replay.cookies.set("session", user_token) + assert replay.get("/me").status_code == 401 + + +def test_session_login_without_a_prior_session() -> None: + example = load_example("session_login") + credentials = {"username": "alice", "password": "alice-demo-password"} + with TestClient(example.app) as client: + assert client.post("/login", json=credentials).status_code == 200 + assert client.get("/me").json() == {"user": "alice", "cart": []} + + +@pytest.mark.skipif( + importlib.util.find_spec("jwt") is None, + reason="session_jwt needs the jwt extra (PyJWT)", +) +def test_session_jwt() -> None: + example = load_example("session_jwt") + credentials = {"username": "alice", "password": "alice-demo-password"} + with TestClient(example.app) as client: + assert client.get("/me").status_code == 401 + issued = client.post("/token", json=credentials) + assert issued.status_code == 200 + token = issued.json()["access_token"] + assert token.count(".") == 2 # header.payload.signature + auth = {"Authorization": f"Bearer {token}"} + + assert client.get("/me", headers=auth).json() == {"user": "alice"} + assert client.post("/logout", headers=auth).status_code == 200 + # The session is gone, so the unexpired JWT no longer works. + assert client.get("/me", headers=auth).status_code == 401 + + +def test_oauth_state() -> None: + example = load_example("oauth_state") + # https: the binding cookie is Secure. + with TestClient(example.app, base_url="https://testserver") as client: + started = client.get("/login", follow_redirects=False) + assert started.status_code == 307 + location = urlsplit(started.headers["location"]) + assert location.netloc == "provider.example.com" + state = parse_qs(location.query)["state"][0] + nonce = client.cookies.get("oauth_binding") + assert nonce + + # A different browser (another binding) is rejected, and that attempt + # consumes the state too. + with TestClient(example.app, base_url="https://testserver") as attacker: + attacker.cookies.set("oauth_binding", "attackers-own-nonce") + rejected = attacker.get( + "/callback", + params={"state": state, "code": "x"}, + follow_redirects=False, + ) + assert rejected.status_code == 400 + assert ( + client.get( + "/callback", + params={"state": state, "code": "x"}, + follow_redirects=False, + ).status_code + == 400 + ) + + +def test_oauth_state_is_consumed_once() -> None: + example = load_example("oauth_state") + with TestClient(example.app, base_url="https://testserver") as client: + started = client.get("/login", follow_redirects=False) + state = parse_qs(urlsplit(started.headers["location"]).query)["state"][0] + nonce = client.cookies.get("oauth_binding") + assert nonce + params = {"state": state, "code": "x"} + + done = client.get("/callback", params=params, follow_redirects=False) + assert done.status_code == 307 + assert done.headers["location"] == "/dashboard" + assert "oauth_binding" not in client.cookies + + # Replaying the callback from the same browser fails: the state is gone. + client.cookies.set("oauth_binding", nonce) + again = client.get("/callback", params=params, follow_redirects=False) + assert again.status_code == 400 + + +def test_cache_lock_serialises_rebuilds() -> None: + example = load_example("cache_lock") + with TestClient(example.app) as client, ThreadPoolExecutor(3) as pool: + results = list( + pool.map( + lambda _: client.post("/reports/sales/rebuild").status_code, range(3) + ) + ) + assert results == [200, 200, 200] + # Three concurrent requests, never more than one inside the lock. + assert example.rebuilds["max_running"] == 1 + + +def test_cache_lock_non_blocking_import_refuses_a_second_run() -> None: + example = load_example("cache_lock") + with TestClient(example.app) as client, ThreadPoolExecutor(2) as pool: + statuses = sorted( + pool.map(lambda _: client.post("/imports").status_code, range(2)) + ) + assert statuses == [200, 409] + # Released afterwards. + assert client.post("/imports").status_code == 200 + + +def test_rate_limit() -> None: + example = load_example("rate_limit") + with TestClient(example.app) as client: + for _ in range(example.LIMIT): + assert client.get("/search", params={"q": "x"}).status_code == 200 + limited = client.get("/search", params={"q": "x"}) + assert limited.status_code == 429 + assert 1 <= int(limited.headers["retry-after"]) <= example.WINDOW + + +@pytest.mark.skipif( + importlib.util.find_spec("redis") is None, + reason="redis_backend needs the redis extra", +) +def test_redis_backend(monkeypatch: pytest.MonkeyPatch) -> None: + reason = redis_skip_reason() + if reason is not None: + pytest.skip(reason) + monkeypatch.setenv("REDIS_HOST", REDIS_HOST) + monkeypatch.setenv("REDIS_PORT", str(REDIS_PORT)) + monkeypatch.delenv("REDIS_PASSWORD", raising=False) + monkeypatch.delenv("REDIS_DB", raising=False) + example = load_example("redis_backend") + with TestClient(example.app) as client: + backend = BackendProxy.get() + client.portal.call(backend.clear) # type: ignore[union-attr] + try: + first = client.get("/hello/redis") + assert first.json() == {"hello": "redis"} + etag = first.headers["etag"] + cached = client.get("/hello/redis", headers={"If-None-Match": etag}) + assert cached.status_code == 304 + assert client.post("/visits").json() == {"visits": 1} + assert client.post("/visits").json() == {"visits": 2} + finally: + client.portal.call(backend.clear) # type: ignore[union-attr] + # The lifespan unregistered the backend on shutdown. + with pytest.raises(BackendNotFoundError): + BackendProxy.get()