from __future__ import annotations import logging from contextlib import asynccontextmanager from time import perf_counter import httpx from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings from app.core.otel import ( TelemetryProviders, configure_otel, instrument_fastapi, instrument_httpx_clients, shutdown_otel, ) from app.core.security import ( FrontendPrincipal, get_internal_token_manager, require_frontend_principal, ) from microservices.common.http import current_trace_headers, with_internal_service_token logging.basicConfig(level=settings.log_level) LOGGER = logging.getLogger(__name__) def _raise_upstream(exc: httpx.HTTPStatusError) -> None: detail = exc.response.text raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc @asynccontextmanager async def lifespan(app: FastAPI): telemetry: TelemetryProviders = configure_otel(settings) instrument_httpx_clients() app.state.http_client = httpx.Client() LOGGER.info("API gateway ready") yield app.state.http_client.close() shutdown_otel(telemetry) app = FastAPI(title="api-gateway-service", version="0.1.0", lifespan=lifespan) instrument_fastapi(app) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["GET", "POST"], allow_headers=["*"], expose_headers=["x-trace-id", "x-span-id"], ) @app.middleware("http") async def security_headers(request: Request, call_next): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "no-referrer" response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" response.headers["X-Permitted-Cross-Domain-Policies"] = "none" response.headers["Strict-Transport-Security"] = ( "max-age=31536000; includeSubDomains" ) response.headers["Cache-Control"] = "no-store" response.headers["Pragma"] = "no-cache" return response def _client() -> httpx.Client: return app.state.http_client def _upstream_headers(principal: FrontendPrincipal) -> dict[str, str]: token = get_internal_token_manager().mint( subject=principal.subject, scopes=principal.scopes, source_service="api-gateway", ) return with_internal_service_token(current_trace_headers(), token) def _get_json(url: str, principal: FrontendPrincipal) -> dict | list: try: response = _client().get( url, headers=_upstream_headers(principal), timeout=settings.request_timeout_seconds, ) response.raise_for_status() return response.json() except httpx.HTTPStatusError as exc: _raise_upstream(exc) def _audit_payload( request: Request, response: Response, started: float, principal: FrontendPrincipal ) -> dict: headers = current_trace_headers() return { "method": request.method, "path": request.url.path, "query_string": request.url.query, "status_code": response.status_code, "duration_ms": (perf_counter() - started) * 1000, "trace_id": headers.get("x-trace-id"), "span_id": headers.get("x-span-id"), "client_ip": request.client.host if request.client else None, "user_agent": request.headers.get("user-agent"), "details": { "subject": principal.subject, "scopes": principal.scopes, }, } def _persist_audit( request: Request, response: Response, started: float, principal: FrontendPrincipal ) -> None: if not request.url.path.startswith("/api/"): return try: _client().post( f"{settings.persistence_service_url.rstrip('/')}/internal/audit-logs", headers=_upstream_headers(principal), json=_audit_payload(request, response, started, principal), timeout=settings.request_timeout_seconds, ).raise_for_status() except httpx.HTTPError as exc: LOGGER.warning("Audit persistence failed: %s", exc) @app.get("/api/health") def health(response: Response) -> dict: response.headers.update(current_trace_headers()) return {"status": "ok", "service": "api-gateway-service"} @app.get("/api/telemetry/status") def telemetry_status( request: Request, response: Response, principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> dict: started = perf_counter() response.headers.update(current_trace_headers()) payload = { "status": "instrumented", "service_name": "api-gateway-service", "collector_endpoint": settings.otel_collector_endpoint, "trace_id": current_trace_headers().get("x-trace-id"), "span_id": current_trace_headers().get("x-span-id"), "trace_headers": ["traceparent", "tracestate", "baggage", "x-trace-id"], "subject": principal.subject, } _persist_audit(request, response, started, principal) return payload @app.get("/api/kpis") def kpis( request: Request, response: Response, principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> dict: started = perf_counter() response.headers.update(current_trace_headers()) payload = _get_json( f"{settings.analytics_service_url.rstrip('/')}/internal/kpis", principal ) _persist_audit(request, response, started, principal) return payload # type: ignore[return-value] @app.get("/api/history") def history( request: Request, response: Response, days_back: int = Query(default=settings.default_history_days, ge=30, le=1460), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: started = perf_counter() response.headers.update(current_trace_headers()) payload = _get_json( f"{settings.analytics_service_url.rstrip('/')}/internal/history?days_back={days_back}", principal, ) _persist_audit(request, response, started, principal) return payload # type: ignore[return-value] @app.get("/api/forecasts") def forecasts( request: Request, response: Response, days: int = Query(default=settings.forecast_horizon_days, ge=7, le=180), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: started = perf_counter() response.headers.update(current_trace_headers()) payload = _get_json( f"{settings.analytics_service_url.rstrip('/')}/internal/forecasts?days={days}", principal, ) _persist_audit(request, response, started, principal) return payload # type: ignore[return-value] @app.get("/api/rankings") def rankings( request: Request, response: Response, top_n: int = Query(default=settings.ranking_default_top_n, ge=3, le=100), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: started = perf_counter() response.headers.update(current_trace_headers()) payload = _get_json( f"{settings.analytics_service_url.rstrip('/')}/internal/rankings?top_n={top_n}", principal, ) _persist_audit(request, response, started, principal) return payload # type: ignore[return-value] @app.get("/api/recommendations") def recommendations( request: Request, response: Response, principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: started = perf_counter() response.headers.update(current_trace_headers()) payload = _get_json( f"{settings.analytics_service_url.rstrip('/')}/internal/recommendations", principal, ) _persist_audit(request, response, started, principal) return payload # type: ignore[return-value] @app.get("/api/dashboard") def dashboard( request: Request, response: Response, principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> dict: started = perf_counter() response.headers.update(current_trace_headers()) payload = _get_json( f"{settings.analytics_service_url.rstrip('/')}/internal/dashboard", principal ) _persist_audit(request, response, started, principal) return payload # type: ignore[return-value] @app.get("/api/storage/audit-logs") def storage_audit_logs( request: Request, response: Response, limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: started = perf_counter() response.headers.update(current_trace_headers()) payload = _get_json( f"{settings.persistence_service_url.rstrip('/')}/internal/audit-logs?limit={limit}", principal, ) _persist_audit(request, response, started, principal) return payload # type: ignore[return-value] @app.get("/api/storage/forecasts") def storage_forecasts( request: Request, response: Response, limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: started = perf_counter() response.headers.update(current_trace_headers()) payload = _get_json( f"{settings.persistence_service_url.rstrip('/')}/internal/forecast-runs?limit={limit}", principal, ) _persist_audit(request, response, started, principal) return payload # type: ignore[return-value] @app.get("/api/storage/rankings") def storage_rankings( request: Request, response: Response, limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: started = perf_counter() response.headers.update(current_trace_headers()) payload = _get_json( f"{settings.persistence_service_url.rstrip('/')}/internal/ranking-runs?limit={limit}", principal, ) _persist_audit(request, response, started, principal) return payload # type: ignore[return-value] @app.get("/api/storage/recommendations") def storage_recommendations( request: Request, response: Response, limit: int = Query(default=settings.storage_default_limit, ge=1, le=500), principal: FrontendPrincipal = Depends(require_frontend_principal), ) -> list[dict]: started = perf_counter() response.headers.update(current_trace_headers()) payload = _get_json( f"{settings.persistence_service_url.rstrip('/')}/internal/recommendation-runs?limit={limit}", principal, ) _persist_audit(request, response, started, principal) return payload # type: ignore[return-value]