feat: implement cache management endpoints and functionality

This commit is contained in:
2026-06-01 00:01:09 +08:00
parent d428f4f321
commit d1e3ce76be
5 changed files with 131 additions and 1 deletions
+5
View File
@@ -93,6 +93,9 @@ Notes:
- List API keys: `GET /admin/api-keys`
- Create API key: `POST /admin/api-keys` with body `{"name":"client-name"}`
- Revoke API key: `DELETE /admin/api-keys/{key_id}`
- Cache status: `GET /admin/cache`
- Invalidate cache by route/query fragment: `POST /admin/cache/invalidate`
- Clear all cache entries: `DELETE /admin/cache`
Optional admin auth:
- Set environment variable `ADMIN_API_KEY` before startup.
@@ -109,6 +112,8 @@ Optional admin auth:
- Configure memory bound with `settings.cache_max_entries`.
- Cache currently applies to proxied `GET`/`HEAD` responses with `2xx` status.
- Gateway adds `x-gateway-cache: MISS` for first fetch and `x-gateway-cache: HIT` for cached responses.
- Admin invalidation payload example:
- `{"path": "/api/v1/bus-arrival", "method": "GET", "query_contains": "BusStopCode=12345"}`
## Scaling and Configurability Guidance
- Add routes through config, not code, for repeatable deployments.
+49
View File
@@ -30,6 +30,12 @@ class ApiKeyCreateRequest(BaseModel):
name: str
class CacheInvalidateRequest(BaseModel):
path: str
method: str = "GET"
query_contains: str | None = None
def _resolve_config_path(explicit_path: str | None = None) -> str:
if explicit_path:
return explicit_path
@@ -122,16 +128,59 @@ def _register_management_routes(app: FastAPI) -> None:
async def admin_dashboard(request: Request) -> dict[str, object]:
require_admin_access(request)
cfg: GatewayConfig = request.app.state.gateway_config
cache: ResponseCache = request.app.state.response_cache
return {
"title": cfg.settings.title,
"version": cfg.settings.version,
"require_api_key": cfg.settings.require_api_key,
"admin_api_key_required": bool(request.app.state.admin_api_key),
"cache_enabled": cfg.settings.cache_enabled,
"cache_entries": cache.size(),
"cache_ttl_seconds": cfg.settings.cache_ttl_seconds,
"cache_max_entries": cfg.settings.cache_max_entries,
"upstreams": sorted(cfg.upstreams.keys()),
"routes_count": len(cfg.routes),
"api_keys_file": str(request.app.state.api_key_store.path),
}
@app.get("/admin/cache", tags=["Admin"], summary="Get cache status")
async def get_cache_status(request: Request) -> dict[str, object]:
require_admin_access(request)
cfg: GatewayConfig = request.app.state.gateway_config
cache: ResponseCache = request.app.state.response_cache
return {
"enabled": cfg.settings.cache_enabled,
"ttl_seconds": cfg.settings.cache_ttl_seconds,
"max_entries": cfg.settings.cache_max_entries,
"entries": cache.size(),
}
@app.delete("/admin/cache", tags=["Admin"], summary="Clear all cached responses")
async def clear_cache(request: Request) -> dict[str, int]:
require_admin_access(request)
cache: ResponseCache = request.app.state.response_cache
cleared = cache.size()
cache.clear()
return {"cleared_entries": cleared}
@app.post("/admin/cache/invalidate", tags=["Admin"], summary="Invalidate cache by route fragment")
async def invalidate_cache(
request: Request,
payload: CacheInvalidateRequest = Body(...),
) -> dict[str, object]:
require_admin_access(request)
cache: ResponseCache = request.app.state.response_cache
fragments = [f"{payload.method.upper()}|", payload.path]
if payload.query_contains:
fragments.append(payload.query_contains)
invalidated = cache.invalidate_by_contains(fragments)
return {
"invalidated_entries": invalidated,
"method": payload.method.upper(),
"path": payload.path,
"query_contains": payload.query_contains,
}
@app.get("/admin/config", tags=["Admin"], summary="Get active gateway YAML config")
async def get_admin_config(request: Request) -> dict[str, str]:
require_admin_access(request)
+16
View File
@@ -64,3 +64,19 @@ class ResponseCache:
def clear(self) -> None:
self._entries.clear()
def size(self) -> int:
self._purge_expired()
return len(self._entries)
def invalidate_by_contains(self, contains: list[str]) -> int:
self._purge_expired()
if not contains:
return 0
invalidated = 0
for key in list(self._entries.keys()):
if all(fragment in key for fragment in contains):
self._entries.pop(key, None)
invalidated += 1
return invalidated
+60
View File
@@ -8,6 +8,7 @@ import pytest
from gateway_framework.api_keys import ApiKeyStore
from gateway_framework.app import create_app
from gateway_framework.cache import ResponseCache
from gateway_framework.config import load_gateway_config
@@ -19,6 +20,7 @@ def _prepare_state(app, config_file: Path) -> None:
app.state.gateway_config_path = Path(config_file)
app.state.admin_api_key = ""
app.state.http_client = httpx.AsyncClient(transport=httpx.MockTransport(failing_upstream))
app.state.response_cache = ResponseCache(ttl_seconds=60, max_entries=100)
store = ApiKeyStore(Path(config_file.parent / "api_keys.json"))
store.ensure_exists()
app.state.api_key_store = store
@@ -203,3 +205,61 @@ routes:
valid = await client.get("/api/v1/demo", headers={"x-api-key": api_key})
assert valid.status_code == 502
@pytest.mark.anyio
async def test_admin_cache_controls(tmp_path) -> None:
config_file = tmp_path / "gateway.yaml"
config_file.write_text(
"""
settings:
cache_enabled: true
upstreams:
demo:
base_url: https://example.com/
routes:
- path: /api/v1/demo
methods: [GET]
upstream: demo
""".strip(),
encoding="utf-8",
)
app = create_app(str(config_file))
_prepare_state(app, config_file)
app.state.response_cache.set(
"GET|https://example.com/api/v1/demo|a=1||",
status_code=200,
headers={"content-type": "application/json"},
body=b"{}",
media_type="application/json",
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
status_before = await client.get("/admin/cache")
assert status_before.status_code == 200
assert status_before.json()["entries"] == 1
invalidated = await client.post(
"/admin/cache/invalidate",
json={"path": "/api/v1/demo", "method": "GET"},
)
assert invalidated.status_code == 200
assert invalidated.json()["invalidated_entries"] == 1
app.state.response_cache.set(
"GET|https://example.com/api/v1/demo|a=2||",
status_code=200,
headers={"content-type": "application/json"},
body=b"{}",
media_type="application/json",
)
cleared = await client.delete("/admin/cache")
assert cleared.status_code == 200
assert cleared.json()["cleared_entries"] == 1
status_after = await client.get("/admin/cache")
assert status_after.status_code == 200
assert status_after.json()["entries"] == 0
Generated
+1 -1
View File
@@ -179,7 +179,7 @@ wheels = [
[[package]]
name = "openapi-api-gateway"
version = "0.1.0"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },