From d1e3ce76be0da3b0573828f005ac18a50c8214c0 Mon Sep 17 00:00:00 2001 From: furyhawk Date: Mon, 1 Jun 2026 00:01:09 +0800 Subject: [PATCH] feat: implement cache management endpoints and functionality --- README.md | 5 +++ src/gateway_framework/app.py | 49 +++++++++++++++++++++++++++ src/gateway_framework/cache.py | 16 +++++++++ tests/test_app.py | 60 ++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 131 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 551ccb9..a3b9805 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/gateway_framework/app.py b/src/gateway_framework/app.py index ddeaf43..f2971ed 100644 --- a/src/gateway_framework/app.py +++ b/src/gateway_framework/app.py @@ -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) diff --git a/src/gateway_framework/cache.py b/src/gateway_framework/cache.py index 8d1163c..e73e9f2 100644 --- a/src/gateway_framework/cache.py +++ b/src/gateway_framework/cache.py @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py index e7d1c9c..c0bbfd0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 diff --git a/uv.lock b/uv.lock index 3d76e93..e29feab 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "openapi-api-gateway" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "fastapi" },