mirror of
https://github.com/furyhawk/api_gateway.git
synced 2026-07-21 02:06:50 +00:00
feat: implement cache management endpoints and functionality
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user