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 findata_core.config import get_settings13from findata_core.logging import configure_logging, get_logger14from starlette.exceptions import HTTPException as StarletteHTTPException15from starlette.middleware.base import BaseHTTPMiddleware1617from findata_api import auth as A18from findata_api import metrics as M19from findata_api.ratelimit import bucket20from findata_api.routers import (21 admin,22 assistant,23 auth,24 catalog,25 coverage,26 exports,27 fundamentals,28 health,29 instruments,30 market,31 quality,32 query,33)3435API_PREFIX = "/api/v1"363738class Observability(BaseHTTPMiddleware):39 async def dispatch(self, request: Request, call_next):40 rid = request.headers.get("x-request-id") or uuid.uuid4().hex[:16]41 request.state.request_id = rid42 t0 = time.monotonic()43 route = request.url.path44 try:45 response = await call_next(request)46 except Exception:47 M.REQUESTS.labels(request.method, route, "500").inc()48 raise49 dt = time.monotonic() - t050 if route.startswith(API_PREFIX):51 M.REQUESTS.labels(request.method, _route_label(route), str(response.status_code)).inc()52 M.LATENCY.labels(_route_label(route)).observe(dt)53 response.headers["X-Request-ID"] = rid54 response.headers["X-Content-Type-Options"] = "nosniff"55 response.headers["X-Frame-Options"] = "DENY"56 response.headers["Referrer-Policy"] = "no-referrer"57 response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"58 if get_settings().is_production:59 response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"60 if not route.startswith("/api/docs"):61 response.headers["Content-Security-Policy"] = ("default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "62 "script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")63 return response646566def _route_label(path: str) -> str:67 parts = path.split("/")68 return "/".join(p if not _looks_id(p) else "{id}" for p in parts[:6])697071def _looks_id(p: str) -> bool:72 return len(p) >= 16 and all(c in "0123456789abcdef-" for c in p)737475class RateLimit(BaseHTTPMiddleware):76 async def dispatch(self, request: Request, call_next):77 if request.url.path.startswith(API_PREFIX) and not request.url.path.endswith("/health"):78 auth_h = request.headers.get("authorization", "")79 key = auth_h[-24:] if auth_h else (request.cookies.get(A.SESSION_COOKIE, "")[-24:] or (request.client.host if request.client else "anon"))80 ok, remaining = bucket.allow(f"rl:{key}", get_settings().rate_limit_per_minute)81 if not ok:82 return JSONResponse({"error": {"code": "rate_limited", "message": "Too many requests", "request_id": getattr(request.state, "request_id", None)}},83 status_code=429, headers={"Retry-After": "5"})84 return await call_next(request)858687def create_app() -> FastAPI:88 s = get_settings()89 configure_logging()90 log = get_logger("api")91 app = FastAPI(title="FinData OS API", version="1.0.0", openapi_url=f"{API_PREFIX}/openapi.json", docs_url="/api/docs", redoc_url=None,92 description="Private, research-grade financial data lakehouse. All market data © FirstRate Data — no redistribution. "93 "Timestamps are UTC ISO-8601; bars are stamped at bar **open**.")94 app.add_middleware(Observability)95 app.add_middleware(RateLimit)9697 @app.exception_handler(StarletteHTTPException)98 async def http_exc(request: Request, exc: StarletteHTTPException):99 detail = exc.detail if isinstance(exc.detail, dict) else {"code": "http_error", "message": str(exc.detail)}100 detail["request_id"] = getattr(request.state, "request_id", None)101 return JSONResponse({"error": detail}, status_code=exc.status_code, headers=getattr(exc, "headers", None))102103 @app.exception_handler(RequestValidationError)104 async def val_exc(request: Request, exc: RequestValidationError):105 return JSONResponse({"error": {"code": "validation_error", "message": "Invalid request", "details": exc.errors(),106 "request_id": getattr(request.state, "request_id", None)}}, status_code=422)107108 @app.exception_handler(Exception)109 async def any_exc(request: Request, exc: Exception):110 from findata_core.redact import redact111 log.error("unhandled", error=redact(repr(exc), s.secret_values()), path=request.url.path)112 return JSONResponse({"error": {"code": "internal_error", "message": "Internal error", "request_id": getattr(request.state, "request_id", None)}}, status_code=500)113114 for r in (auth.router, catalog.router, instruments.router, market.router, fundamentals.router, query.router, exports.router,115 coverage.router, quality.router, health.router, admin.router, assistant.router):116 app.include_router(r, prefix=API_PREFIX)117118 @app.get("/metrics", include_in_schema=False)119 def metrics(request: Request, p: A.Principal = Depends(A.get_principal)):120 host = request.client.host if request.client else ""121 private = host in ("127.0.0.1", "::1") or host.startswith(("192.168.", "10.67.", "10.", "172.16."))122 if not private and not p.has("admin"):123 return JSONResponse({"error": {"code": "forbidden", "message": "metrics require admin or a private network"}}, status_code=403)124 from findata_api.health import evaluate125 evaluate()126 body, ctype = M.render()127 return Response(body, media_type=ctype)128129 web = s.web_dist or Path(s.root) / "apps" / "web" / "out"130 if web.exists():131 app.mount("/", StaticFiles(directory=str(web), html=True), name="web")132 log.info("web.mounted", path=str(web))133 else:134 @app.get("/", include_in_schema=False)135 def root():136 return {"service": "FinData OS", "docs": "/api/docs", "note": "web console not built"}137 return app138139140app = create_app()141