Files
zavrsni-rad-otel-app/backend/microservices/api_gateway/main.py

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]