1"""FastAPI application: /api/v1 (OpenAPI at /api/v1/openapi.json, docs at /api/docs), /metrics, static console."""2from __future__ import annotations34import time5import uuid6from pathlib import Path78from fastapi import Depends, FastAPI, Request9from fastapi.exceptions import RequestValidationError10from fastapi.responses import JSONResponse, Response11from fastapi.staticfiles import StaticFiles12from starlette.exceptions import HTTPException as StarletteHTTPException13from starlette.middleware.base import BaseHTTPMiddleware1415from findata_api import auth as A16from findata_api import metrics as M17from findata_api.ratelimit import bucket18from findata_api.routers import admin, auth, catalog, coverage, exports, fundamentals, health, instruments, market, quality, query19from findata_core.config import get_settings20from findata_core.logging import configure_logging, get_logger2122API_PREFIX = "/api/v1"232425class Observability(BaseHTTPMiddleware):26 async def dispatch(self, request: Request, call_next): # noqa: ANN00127 rid = request.headers.get("x-request-id") or uuid.uuid4().hex[:16]28 request.state.request_id = rid29 t0 = time.monotonic()30 route = request.url.path31 try:32 response = await call_next(request)33 except Exception: # noqa: BLE00134 M.REQUESTS.labels(request.method, route, "500").inc()35 raise36 dt = time.monotonic() - t037 if route.startswith(API_PREFIX):38 M.REQUESTS.labels(request.method, _route_label(route), str(response.status_code)).inc()39 M.LATENCY.labels(_route_label(route)).observe(dt)40 response.headers["X-Request-ID"] = rid41 response.headers["X-Content-Type-Options"] = "nosniff"42 response.headers["X-Frame-Options"] = "DENY"43 response.headers["Referrer-Policy"] = "no-referrer"44 response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"45 if get_settings().is_production:46 response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"47 if not route.startswith("/api/docs"):48 response.headers["Content-Security-Policy"] = ("default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "49 "script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")50 return response515253def _route_label(path: str) -> str:54 parts = path.split("/")55 return "/".join(p if not _looks_id(p) else "{id}" for p in parts[:6])565758def _looks_id(p: str) -> bool:59 return len(p) >= 16 and all(c in "0123456789abcdef-" for c in p)606162class RateLimit(BaseHTTPMiddleware):63 async def dispatch(self, request: Request, call_next): # noqa: ANN00164 if request.url.path.startswith(API_PREFIX) and not request.url.path.endswith("/health"):65 auth_h = request.headers.get("authorization", "")66 key = auth_h[-24:] if auth_h else (request.cookies.get(A.SESSION_COOKIE, "")[-24:] or (request.client.host if request.client else "anon"))67 ok, remaining = bucket.allow(f"rl:{key}", get_settings().rate_limit_per_minute)68 if not ok:69 return JSONResponse({"error": {"code": "rate_limited", "message": "Too many requests", "request_id": getattr(request.state, "request_id", None)}},70 status_code=429, headers={"Retry-After": "5"})71 return await call_next(request)727374def create_app() -> FastAPI:75 s = get_settings()76 configure_logging()77 log = get_logger("api")78 app = FastAPI(title="FinData OS API", version="1.0.0", openapi_url=f"{API_PREFIX}/openapi.json", docs_url="/api/docs", redoc_url=None,79 description="Private, research-grade financial data lakehouse. All market data © FirstRate Data — no redistribution. "80 "Timestamps are UTC ISO-8601; bars are stamped at bar **open**.")81 app.add_middleware(Observability)82 app.add_middleware(RateLimit)8384 @app.exception_handler(StarletteHTTPException)85 async def http_exc(request: Request, exc: StarletteHTTPException): # noqa: ANN00186 detail = exc.detail if isinstance(exc.detail, dict) else {"code": "http_error", "message": str(exc.detail)}87 detail["request_id"] = getattr(request.state, "request_id", None)88 return JSONResponse({"error": detail}, status_code=exc.status_code, headers=getattr(exc, "headers", None))8990 @app.exception_handler(RequestValidationError)91 async def val_exc(request: Request, exc: RequestValidationError): # noqa: ANN00192 return JSONResponse({"error": {"code": "validation_error", "message": "Invalid request", "details": exc.errors(),93 "request_id": getattr(request.state, "request_id", None)}}, status_code=422)9495 @app.exception_handler(Exception)96 async def any_exc(request: Request, exc: Exception): # noqa: ANN00197 from findata_core.redact import redact98 log.error("unhandled", error=redact(repr(exc), s.secret_values()), path=request.url.path)99 return JSONResponse({"error": {"code": "internal_error", "message": "Internal error", "request_id": getattr(request.state, "request_id", None)}}, status_code=500)100101 for r in (auth.router, catalog.router, instruments.router, market.router, fundamentals.router, query.router, exports.router,102 coverage.router, quality.router, health.router, admin.router):103 app.include_router(r, prefix=API_PREFIX)104105 @app.get("/metrics", include_in_schema=False)106 def metrics(request: Request, p: A.Principal = Depends(A.get_principal)):107 host = request.client.host if request.client else ""108 private = host in ("127.0.0.1", "::1") or host.startswith(("192.168.", "10.67.", "10.", "172.16."))109 if not private and not p.has("admin"):110 return JSONResponse({"error": {"code": "forbidden", "message": "metrics require admin or a private network"}}, status_code=403)111 from findata_api.health import evaluate112 evaluate()113 body, ctype = M.render()114 return Response(body, media_type=ctype)115116 web = s.web_dist or Path(s.root) / "apps" / "web" / "out"117 if web.exists():118 app.mount("/", StaticFiles(directory=str(web), html=True), name="web")119 log.info("web.mounted", path=str(web))120 else:121 @app.get("/", include_in_schema=False)122 def root():123 return {"service": "FinData OS", "docs": "/api/docs", "note": "web console not built"}124 return app125126127app = create_app()128