"""FastAPI application — `/api/v1`. Loopback-only in production (Next.js proxies `/api/v1/*`); docs at `/api/v1/docs`. Routers live in `companyatlas.api.routers.` and expose `router`; they are auto-included in alphabetical order, except that modules listing `ORDER = n` are sorted by that first (literal paths must be registered before `/{slug}` catch-alls). """ from __future__ import annotations import asyncio import importlib import logging import pkgutil import time 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 companyatlas from companyatlas.api.common import AtlasJSONResponse from companyatlas.config import settings from companyatlas.db import connection, dispose, fetch_val from companyatlas.logging import setup_logging log = logging.getLogger("companyatlas.api") API_VERSION = "1.0" _STARTED = time.time() @asynccontextmanager async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] setup_logging(service="ca-api") settings.ensure_dirs() log.info("api started", extra={"version": companyatlas.__version__, "api": API_VERSION, "env": settings.app_env, "port": settings.api_port}) yield await dispose() app = FastAPI(title="Company Atlas API", version=companyatlas.__version__, lifespan=lifespan, default_response_class=AtlasJSONResponse, docs_url="/api/v1/docs", redoc_url=None, openapi_url="/api/v1/openapi.json", description="The live atlas of global companies: companies, sensors, observations, changes, structured events, metrics, rankings, " "industries and countries — with provenance and history on every fact. Contract: docs/API.md.") app.add_middleware(GZipMiddleware, minimum_size=1024) _origins = {settings.site_url, "https://www.company-atlas.co", "https://company-atlas.co", "https://www.company-atlas.com", "https://company-atlas.com", "http://localhost:8370", "http://127.0.0.1:8370", "http://localhost:8360", "http://127.0.0.1:8360"} app.add_middleware(CORSMiddleware, allow_origins=sorted(o for o in _origins if o), allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allow_headers=["*"], expose_headers=["etag", "cache-control", "x-api-version", "x-ratelimit-remaining"], 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 = 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 return {"status": "ok" if db_ok else "degraded", "version": companyatlas.__version__, "api_version": API_VERSION, "db": db_ok, "llm": {"configured": settings.llm_configured}, "uptime_s": int(time.time() - _STARTED), "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() @app.get("/ready", tags=["health"]) async def ready() -> dict[str, Any]: h = await _health() return {"ready": h["db"]} def _include_routers() -> None: import companyatlas.api.routers as pkg mods = [] for m in pkgutil.iter_modules(pkg.__path__): if m.name.startswith("_"): continue module = importlib.import_module(f"companyatlas.api.routers.{m.name}") router = getattr(module, "router", None) if router is not None: mods.append((getattr(module, "ORDER", 50), m.name, router)) for _order, _name, router in sorted(mods, key=lambda x: (x[0], x[1])): app.include_router(router) _include_routers() from companyatlas.api.ratelimit import rate_limit_middleware # registered last on purpose: outermost app-level hook app.middleware("http")(rate_limit_middleware) __all__ = ["API_VERSION", "app"]