spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1"""FastAPI application — `/api/v1`. Loopback-only in production (Next.js proxies `/api/v1/*`); docs at `/api/v1/docs`.23Routers live in `companyatlas.api.routers.<name>` and expose `router`; they are auto-included in alphabetical order, except that4modules listing `ORDER = n` are sorted by that first (literal paths must be registered before `/{slug}` catch-alls).5"""6from __future__ import annotations78import asyncio9import importlib10import logging11import pkgutil12import time13from contextlib import asynccontextmanager14from datetime import UTC, datetime15from typing import Any1617from fastapi import FastAPI, Request18from fastapi.exceptions import RequestValidationError19from fastapi.middleware.cors import CORSMiddleware20from fastapi.middleware.gzip import GZipMiddleware21from starlette.exceptions import HTTPException as StarletteHTTPException2223import companyatlas24from companyatlas.api.common import AtlasJSONResponse25from companyatlas.config import settings26from companyatlas.db import connection, dispose, fetch_val27from companyatlas.logging import setup_logging2829log = logging.getLogger("companyatlas.api")30API_VERSION = "1.0"31_STARTED = time.time()323334@asynccontextmanager35async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]36 setup_logging(service="ca-api")37 settings.ensure_dirs()38 log.info("api started", extra={"version": companyatlas.__version__, "api": API_VERSION, "env": settings.app_env, "port": settings.api_port})39 yield40 await dispose()414243app = FastAPI(title="Company Atlas API", version=companyatlas.__version__, lifespan=lifespan, default_response_class=AtlasJSONResponse,44 docs_url="/api/v1/docs", redoc_url=None, openapi_url="/api/v1/openapi.json",45 description="The live atlas of global companies: companies, sensors, observations, changes, structured events, metrics, rankings, "46 "industries and countries — with provenance and history on every fact. Contract: docs/API.md.")4748app.add_middleware(GZipMiddleware, minimum_size=1024)49_origins = {settings.site_url, "https://www.company-atlas.co", "https://company-atlas.co", "https://www.company-atlas.com", "https://company-atlas.com",50 "http://localhost:8370", "http://127.0.0.1:8370", "http://localhost:8360", "http://127.0.0.1:8360"}51app.add_middleware(CORSMiddleware, allow_origins=sorted(o for o in _origins if o), allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],52 allow_headers=["*"], expose_headers=["etag", "cache-control", "x-api-version", "x-ratelimit-remaining"], max_age=600)535455@app.middleware("http")56async def security_headers(request: Request, call_next): # type: ignore[no-untyped-def]57 try:58 response = await call_next(request)59 except Exception:60 log.exception("unhandled error", extra={"route": request.url.path})61 return AtlasJSONResponse({"detail": "internal server error"}, status_code=500)62 response.headers["x-content-type-options"] = "nosniff"63 response.headers["referrer-policy"] = "strict-origin-when-cross-origin"64 response.headers["x-api-version"] = API_VERSION65 return response666768@app.exception_handler(StarletteHTTPException)69async def http_error(request: Request, exc: StarletteHTTPException): # type: ignore[no-untyped-def]70 detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)71 return AtlasJSONResponse({"detail": detail}, status_code=exc.status_code, headers=dict(exc.headers or {}))727374@app.exception_handler(RequestValidationError)75async def validation_error(request: Request, exc: RequestValidationError): # type: ignore[no-untyped-def]76 errs = exc.errors()[:5]77 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"78 return AtlasJSONResponse({"detail": msg, "errors": [{"loc": e.get("loc"), "msg": e.get("msg"), "type": e.get("type")} for e in errs]}, status_code=422)798081@app.exception_handler(Exception)82async def unhandled(request: Request, exc: Exception): # type: ignore[no-untyped-def]83 log.exception("unhandled error", extra={"route": request.url.path})84 return AtlasJSONResponse({"detail": "internal server error"}, status_code=500)858687async def _health() -> dict[str, Any]:88 db_ok = False89 try:90 async with connection() as conn:91 db_ok = (await asyncio.wait_for(fetch_val(conn, "select 1"), timeout=3)) == 192 except Exception: # noqa: BLE00193 db_ok = False94 return {"status": "ok" if db_ok else "degraded", "version": companyatlas.__version__, "api_version": API_VERSION, "db": db_ok,95 "llm": {"configured": settings.llm_configured}, "uptime_s": int(time.time() - _STARTED), "time": datetime.now(UTC)}969798@app.get("/health", tags=["health"])99async def health_root() -> dict[str, Any]:100 return await _health()101102103@app.get("/api/v1/health", tags=["health"])104async def health_v1() -> dict[str, Any]:105 return await _health()106107108@app.get("/ready", tags=["health"])109async def ready() -> dict[str, Any]:110 h = await _health()111 return {"ready": h["db"]}112113114def _include_routers() -> None:115 import companyatlas.api.routers as pkg116117 mods = []118 for m in pkgutil.iter_modules(pkg.__path__):119 if m.name.startswith("_"):120 continue121 module = importlib.import_module(f"companyatlas.api.routers.{m.name}")122 router = getattr(module, "router", None)123 if router is not None:124 mods.append((getattr(module, "ORDER", 50), m.name, router))125 for _order, _name, router in sorted(mods, key=lambda x: (x[0], x[1])):126 app.include_router(router)127128129_include_routers()130131from companyatlas.api.ratelimit import rate_limit_middleware # registered last on purpose: outermost app-level hook132133app.middleware("http")(rate_limit_middleware)134135__all__ = ["API_VERSION", "app"]136