"""FastAPI application — `/api/v1` (API 1.1). Loopback-only in production (Next.js proxies `/api/v1/*`); docs at `/api/v1/docs`.""" from __future__ import annotations import asyncio import logging from contextlib import asynccontextmanager from datetime import UTC, datetime from typing import Any from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from starlette.exceptions import HTTPException as StarletteHTTPException import aiatlas from aiatlas.api.common import AtlasJSONResponse from aiatlas.api.etag import ETagMiddleware from aiatlas.api.routers import ( admin, admin_quality, benchmarks, changes, claims, companies, compare, deployments, diff, entities, explore, families, graph, hardware, intelligence, misc, models, papers, prices, providers, search, sources, stats, timeline, ) from aiatlas.config import settings from aiatlas.db import connection, dispose, fetch_val from aiatlas.logging import setup_logging from aiatlas.services import cache from aiatlas.services.llm import gateway log = logging.getLogger("aiatlas.api") API_VERSION = "1.1" @asynccontextmanager async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] setup_logging(service="aia-api") settings.ensure_dirs() log.info("api started", extra={"version": aiatlas.__version__, "api": API_VERSION, "env": settings.app_env, "port": settings.api_port}) yield await cache.close() await dispose() app = FastAPI(title="AI Atlas API", version=aiatlas.__version__, lifespan=lifespan, default_response_class=AtlasJSONResponse, docs_url="/api/v1/docs", redoc_url=None, openapi_url="/api/v1/openapi.json", description="The global intelligence layer for artificial intelligence: models, companies, papers, providers, prices, benchmarks, hardware — " "with provenance and history on every fact. Contract: docs/API.md (API 1.1, additive over v1).") # Middleware order: the LAST added is the OUTERMOST. ETag must be inside GZip (hash of the uncompressed body), so it is added first. app.add_middleware(ETagMiddleware) app.add_middleware(GZipMiddleware, minimum_size=1024) _origins = {settings.site_url, "https://www.ai-atlas.co", "https://ai-atlas.co", "http://localhost:8320", "http://127.0.0.1:8320", "http://localhost:8330", "http://127.0.0.1:8330"} app.add_middleware(CORSMiddleware, allow_origins=sorted(o for o in _origins if o), allow_methods=["GET", "POST", "PATCH", "OPTIONS"], allow_headers=["*"], expose_headers=["etag", "cache-control", "x-api-version"], max_age=600) @app.middleware("http") async def security_headers(request: Request, call_next): # type: ignore[no-untyped-def] try: response = await call_next(request) except Exception: log.exception("unhandled error", extra={"route": request.url.path}) return AtlasJSONResponse({"detail": "internal server error"}, status_code=500) response.headers["x-content-type-options"] = "nosniff" response.headers["referrer-policy"] = "strict-origin-when-cross-origin" response.headers["x-api-version"] = API_VERSION return response @app.exception_handler(StarletteHTTPException) async def http_error(request: Request, exc: StarletteHTTPException): # type: ignore[no-untyped-def] detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail) return AtlasJSONResponse({"detail": detail}, status_code=exc.status_code, headers=dict(exc.headers or {})) @app.exception_handler(RequestValidationError) async def validation_error(request: Request, exc: RequestValidationError): # type: ignore[no-untyped-def] errs = exc.errors()[:5] msg = "; ".join(f"{'.'.join(str(x) for x in e.get('loc', []) if x not in ('query', 'body', 'path'))}: {e.get('msg')}" for e in errs) or "invalid request" return AtlasJSONResponse({"detail": msg, "errors": [{"loc": e.get("loc"), "msg": e.get("msg"), "type": e.get("type")} for e in errs]}, status_code=422) @app.exception_handler(Exception) async def unhandled(request: Request, exc: Exception): # type: ignore[no-untyped-def] log.exception("unhandled error", extra={"route": request.url.path}) return AtlasJSONResponse({"detail": "internal server error"}, status_code=500) async def _health() -> dict[str, Any]: db_ok = redis_ok = False try: async with connection() as conn: db_ok = (await asyncio.wait_for(fetch_val(conn, "select 1"), timeout=3)) == 1 except Exception: # noqa: BLE001 db_ok = False try: redis_ok = bool(await asyncio.wait_for(cache.redis().ping(), timeout=2)) except Exception: # noqa: BLE001 redis_ok = False llm: dict[str, Any] = {"available": gateway.available} if gateway.available: cached = await cache.cache_get("health:llm") if cached is None: try: cached = {"reachable": bool(await asyncio.wait_for(gateway.engine.health(), timeout=2.5))} except Exception: # noqa: BLE001 cached = {"reachable": False} await cache.cache_set("health:llm", cached, 300) llm.update(cached) return {"status": "ok" if db_ok else "degraded", "version": aiatlas.__version__, "api_version": API_VERSION, "db": db_ok, "redis": redis_ok, "llm": llm, "time": datetime.now(UTC)} @app.get("/health", tags=["health"]) async def health_root() -> dict[str, Any]: return await _health() @app.get("/api/v1/health", tags=["health"]) async def health_v1() -> dict[str, Any]: return await _health() # Order matters only where a literal path and a `{param}` path share a prefix — literal routes live in the same router and are declared first. # `intelligence`, `families`, `graph`, `claims`, `deployments` and `misc` mount literal paths under /api/v1 and must precede `entities` (/{slug_or_id}). for r in (stats, search, models, companies, papers, providers, prices, deployments, benchmarks, hardware, explore, changes, timeline, compare, diff, sources, intelligence, families, graph, claims, misc, entities, admin, admin_quality): app.include_router(r.router) __all__ = ["API_VERSION", "app"]