Add per-key OpenAPI document and Swagger UI portal

GET /openapi.json (X-API-Key authenticated) merges the upstream OpenAPI
documents into one spec scoped to the calling key: only granted
operations, paths rewritten to gateway routes, component schemas
namespaced per service. GET /docs serves a Swagger UI portal that loads
the key-scoped spec and injects the key into try-it-out requests.

Discovery now caches the raw upstream spec documents (same 5-minute
TTL), and FastAPI's built-in /docs and /openapi.json are disabled in
favor of the portal routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Amar
2026-07-29 14:27:44 +02:00
co-authored by Claude Fable 5
parent 77d7a50fa9
commit d5878f6130
4 changed files with 231 additions and 2 deletions
+11
View File
@@ -15,6 +15,7 @@ SPEC_PATHS = ("/openapi.json", "/swagger.json", "/api-docs")
SYNC_TTL_SECONDS = 300
_last_sync: dict[int, float] = {} # service id -> monotonic time of last attempt
_spec_found: dict[int, bool] = {} # service id -> did the last attempt find a spec
_spec_cache: dict[int, dict] = {} # service id -> raw OpenAPI document of last fetch
def fetch_spec(base_url: str, verify_tls: bool = True) -> dict | None:
@@ -51,9 +52,18 @@ def sync_service(db: Session, service: Service, force: bool = False) -> tuple[bo
_spec_found[service.id] = spec is not None
if spec is None:
return False, False
_spec_cache[service.id] = spec
return True, apply_spec(db, service, spec)
def get_spec(db: Session, service: Service) -> dict | None:
"""Raw upstream OpenAPI document, refreshed through the same TTL cache as
the endpoint sync. Falls back to the last known document if the upstream
is temporarily unreachable."""
sync_service(db, service)
return _spec_cache.get(service.id)
def validate_service(db: Session, service: Service) -> dict:
"""Connectivity probe for the admin UI. Prefers the OpenAPI document —
finding one both proves reachability and refreshes the endpoint cache —
@@ -63,6 +73,7 @@ def validate_service(db: Session, service: Service) -> dict:
if spec is not None:
_last_sync[service.id] = time.monotonic()
_spec_found[service.id] = True
_spec_cache[service.id] = spec
apply_spec(db, service, spec)
return {"ok": True, "spec_found": True,
"endpoints": len(service.endpoints),