Add initial work from Codex
This commit is contained in:
326
backend/microservices/api_gateway/main.py
Normal file
326
backend/microservices/api_gateway/main.py
Normal file
@@ -0,0 +1,326 @@
|
||||
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]
|
||||
Reference in New Issue
Block a user