327 lines
11 KiB
Python
327 lines
11 KiB
Python
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]
|