diff --git a/README.md b/README.md index 4c2e3b4..eabdaea 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,11 @@ with a built-in web management console. codes, latency by service, top keys — filterable by time range (1 h – 30 d), service, user and key. +- **Consumer docs** — `GET /openapi.json` (authenticated with `X-API-Key`) + returns a merged OpenAPI document scoped to that key: every operation the + key holds a grant on, across all services, rewritten to the gateway's + routes. `GET /docs` serves a Swagger UI portal around it — paste a key, + browse and try exactly what that key can call. - **Retention** — a background purge runs every 6 hours: request/response payloads are blanked after 7 days (the log row stays inspectable), whole log rows are deleted after 90 days, and freed pages are returned to the diff --git a/app/discovery.py b/app/discovery.py index 891b570..02ad92b 100644 --- a/app/discovery.py +++ b/app/discovery.py @@ -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), diff --git a/app/main.py b/app/main.py index c133d69..818285e 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import RedirectResponse from sqlalchemy import text -from app import config, proxy, retention, security +from app import config, portal, proxy, retention, security from app.admin import routes as admin_routes from app.admin import stats as admin_stats from app.admin.deps import LoginRequired, login_redirect_handler @@ -111,7 +111,10 @@ async def lifespan(app: FastAPI): await proxy.close_client() -app = FastAPI(title="API Gateway", version="2.0.0", lifespan=lifespan) +# Built-in docs/openapi are disabled: the gateway serves its own consumer-facing +# /docs and per-key /openapi.json (app/portal.py) at those paths instead. +app = FastAPI(title="API Gateway", version="2.0.0", lifespan=lifespan, + docs_url=None, redoc_url=None, openapi_url=None) app.add_exception_handler(LoginRequired, login_redirect_handler) @@ -127,6 +130,7 @@ def health(): app.include_router(admin_routes.router) app.include_router(admin_stats.router) +app.include_router(portal.router) app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static") # The proxy catch-all (/{slug}/...) must come last so it never shadows # /admin, /static, /docs or /health. diff --git a/app/portal.py b/app/portal.py new file mode 100644 index 0000000..5a68930 --- /dev/null +++ b/app/portal.py @@ -0,0 +1,209 @@ +"""Consumer-facing API documentation. + +`GET /openapi.json` returns a merged OpenAPI document scoped to the calling +API key: every operation the key holds a grant on, across all services, +rewritten to the gateway's own routes (`/{slug}/...`). `GET /docs` serves a +Swagger UI portal around it. +""" +import re + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, JSONResponse +from sqlalchemy.orm import Session + +from app import config, discovery, security +from app.database import get_db +from app.models import ApiKey, Endpoint, Service +from app.proxy import _error, _pattern_to_regex + +router = APIRouter() + +_HTTP_METHODS = ("get", "post", "put", "patch", "delete", "head", "options") +_REF_RE = re.compile(r"^#/components/([A-Za-z]+)/(.+)$") + + +def _rewrite_refs(obj, slug: str): + """Namespace `$ref`s into a service's renamed components (atlas_Error).""" + if isinstance(obj, dict): + out = {} + for key, value in obj.items(): + if key == "$ref" and isinstance(value, str): + m = _REF_RE.match(value) + out[key] = (f"#/components/{m.group(1)}/{slug}_{m.group(2)}" + if m else value) + else: + out[key] = _rewrite_refs(value, slug) + return out + if isinstance(obj, list): + return [_rewrite_refs(item, slug) for item in obj] + return obj + + +def _grant_matches(grant: Endpoint, method: str, path: str) -> bool: + return (grant.method in ("*", method) + and _pattern_to_regex(grant.path).match(path) is not None) + + +def _service_paths(service: Service, grants: list[Endpoint], spec: dict | None) -> dict: + """The slug-prefixed path entries of one service, filtered to the grants.""" + slug = service.slug + if spec: + included = {} + for path, operations in spec.get("paths", {}).items(): + if not isinstance(operations, dict): + continue + kept = {} + for method, op in operations.items(): + if method.lower() not in _HTTP_METHODS or not isinstance(op, dict): + continue + if any(_grant_matches(g, method.upper(), path) for g in grants): + kept[method] = {**op, "tags": [service.name]} + if kept: + entry = {k: v for k, v in operations.items() if k.lower() not in _HTTP_METHODS} + entry.update(kept) + included[f"/{slug}{path}"] = entry + return _rewrite_refs(included, slug) + + # No OpenAPI document upstream — synthesize minimal entries from the catalog. + included = {} + for g in grants: + methods = [g.method.lower()] if g.method != "*" else ["get", "post", "put", "patch", "delete"] + entry = included.setdefault(f"/{slug}{g.path}", {}) + for method in methods: + entry[method] = { + "summary": g.description or ("(any method)" if g.method == "*" else ""), + "tags": [service.name], + "responses": {"default": {"description": "Proxied upstream response"}}, + } + return included + + +def build_key_spec(db: Session, api_key: ApiKey, gateway_url: str) -> dict: + by_service: dict[int, list[Endpoint]] = {} + for grant in api_key.endpoints: + by_service.setdefault(grant.service_id, []).append(grant) + + paths: dict = {} + components: dict = {} + tags: list[dict] = [] + for service_id, grants in by_service.items(): + service = grants[0].service + if not service.is_active: + continue + spec = discovery.get_spec(db, service) + paths.update(_service_paths(service, grants, spec)) + if spec: + for section, items in spec.get("components", {}).items(): + if section == "securitySchemes" or not isinstance(items, dict): + continue + renamed = {f"{service.slug}_{name}": schema for name, schema in items.items()} + components.setdefault(section, {}).update(_rewrite_refs(renamed, service.slug)) + tags.append({"name": service.name, "description": service.description or f"/{service.slug}"}) + + components["securitySchemes"] = { + "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": config.API_KEY_HEADER}, + } + return { + "openapi": "3.0.3", + "info": { + "title": "API Gateway", + "version": "1.0.0", + "description": f"Operations available to API key **{api_key.name}**. " + f"Send the key in the `{config.API_KEY_HEADER}` header.", + }, + "servers": [{"url": gateway_url}], + "tags": sorted(tags, key=lambda t: t["name"]), + "paths": dict(sorted(paths.items())), + "components": components, + "security": [{"ApiKeyAuth": []}], + } + + +@router.get("/openapi.json", include_in_schema=False) +def key_scoped_openapi(request: Request, db: Session = Depends(get_db)): + plain_key = request.headers.get(config.API_KEY_HEADER) + if not plain_key: + return _error(401, "missing_api_key", + f"Provide your API key in the {config.API_KEY_HEADER} header.") + api_key = (db.query(ApiKey) + .filter(ApiKey.key_hash == security.hash_api_key(plain_key)) + .one_or_none()) + if api_key is None or not api_key.is_active or not api_key.user.is_active: + return _error(403, "invalid_api_key", "API key is unknown or has been revoked.") + + spec = build_key_spec(db, api_key, str(request.base_url).rstrip("/")) + return JSONResponse(spec, headers={"Cache-Control": "no-store"}) + + +_DOCS_PAGE = """ + +
+ + +