SPB Git

spb/findata

Public
6.5 KB · 128 lines python
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
Files · d6d35c99819515b0fe452508c04019b74b1f802d
Menu

Theme