SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
5.8 KB · 136 lines python
Raw Blame History
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