SPB Git

spb/findata

Public
8.3 KB · 177 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 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    public,32    quality,33    query,34)3536API_PREFIX = "/api/v1"373839class Observability(BaseHTTPMiddleware):40    async def dispatch(self, request: Request, call_next):41        rid = request.headers.get("x-request-id") or uuid.uuid4().hex[:16]42        request.state.request_id = rid43        t0 = time.monotonic()44        route = request.url.path45        try:46            response = await call_next(request)47        except Exception:48            M.REQUESTS.labels(request.method, route, "500").inc()49            raise50        dt = time.monotonic() - t051        if route.startswith(API_PREFIX):52            M.REQUESTS.labels(request.method, _route_label(route), str(response.status_code)).inc()53            M.LATENCY.labels(_route_label(route)).observe(dt)54        response.headers["X-Request-ID"] = rid55        if not route.startswith("/api/") and route != "/metrics":56            # static console: hashed assets are immutable, HTML must be revalidated so deployments show up at once57            if route.startswith("/_next/static/"):58                response.headers["Cache-Control"] = "public, max-age=31536000, immutable"59            else:60                response.headers["Cache-Control"] = "no-cache"61        response.headers["X-Content-Type-Options"] = "nosniff"62        response.headers["X-Frame-Options"] = "DENY"63        response.headers["Referrer-Policy"] = "no-referrer"64        response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"65        if get_settings().is_production:66            response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"67        if not route.startswith("/api/docs"):68            response.headers["Content-Security-Policy"] = ("default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "69                                                           "script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")70        return response717273def _route_label(path: str) -> str:74    parts = path.split("/")75    return "/".join(p if not _looks_id(p) else "{id}" for p in parts[:6])767778def _looks_id(p: str) -> bool:79    return len(p) >= 16 and all(c in "0123456789abcdef-" for c in p)808182TRUSTED_PREFIXES = ("127.", "::1", "10.67.", "192.168.", "10.", "172.16.", "172.17.", "172.18.", "172.19.", "172.2", "172.30.", "172.31.")838485class SourceGuard(BaseHTTPMiddleware):86    """The API listens on all interfaces so the WireGuard peer (Caddy on BHS64 via wg1) can reach it; only loopback,87    the tunnel subnet and the LAN may connect. Caddy terminates TLS and forwards the real client in X-Forwarded-For."""8889    async def dispatch(self, request: Request, call_next):90        peer = request.client.host if request.client else ""91        if not peer.startswith(TRUSTED_PREFIXES) and peer != "testclient":92            return JSONResponse({"error": {"code": "forbidden", "message": "untrusted network"}}, status_code=403)93        # real client address for audit/rate limiting: X-Forwarded-For is trusted only from the tunnel gateway94        client_ip = peer95        if peer.startswith("10.67.") and request.headers.get("x-forwarded-for"):96            client_ip = request.headers["x-forwarded-for"].split(",")[0].strip()97        request.state.client_ip = client_ip98        return await call_next(request)99100101class RateLimit(BaseHTTPMiddleware):102    async def dispatch(self, request: Request, call_next):103        if request.url.path.startswith(API_PREFIX) and not request.url.path.endswith("/health"):104            auth_h = request.headers.get("authorization", "")105            key = auth_h[-24:] if auth_h else (request.cookies.get(A.SESSION_COOKIE, "")[-24:] or (A.client_ip(request) or "anon"))106            if request.url.path.startswith(f"{API_PREFIX}/public/"):107                key = f"public:{A.client_ip(request) or 'anon'}"108            ok, remaining = bucket.allow(f"rl:{key}", get_settings().rate_limit_per_minute)109            if not ok:110                return JSONResponse({"error": {"code": "rate_limited", "message": "Too many requests", "request_id": getattr(request.state, "request_id", None)}},111                                    status_code=429, headers={"Retry-After": "5"})112        return await call_next(request)113114115def create_app() -> FastAPI:116    s = get_settings()117    configure_logging()118    log = get_logger("api")119    app = FastAPI(title="FinData OS API", version="1.0.0", openapi_url=f"{API_PREFIX}/openapi.json", docs_url="/api/docs", redoc_url=None,120                  description="Private, research-grade financial data lakehouse. All market data © FirstRate Data — no redistribution. "121                              "Timestamps are UTC ISO-8601; bars are stamped at bar **open**.")122    app.add_middleware(Observability)123    app.add_middleware(RateLimit)124    app.add_middleware(SourceGuard)125126    @app.exception_handler(StarletteHTTPException)127    async def http_exc(request: Request, exc: StarletteHTTPException):128        detail = exc.detail if isinstance(exc.detail, dict) else {"code": "http_error", "message": str(exc.detail)}129        detail["request_id"] = getattr(request.state, "request_id", None)130        return JSONResponse({"error": detail}, status_code=exc.status_code, headers=getattr(exc, "headers", None))131132    @app.exception_handler(RequestValidationError)133    async def val_exc(request: Request, exc: RequestValidationError):134        return JSONResponse({"error": {"code": "validation_error", "message": "Invalid request", "details": exc.errors(),135                                       "request_id": getattr(request.state, "request_id", None)}}, status_code=422)136137    @app.exception_handler(Exception)138    async def any_exc(request: Request, exc: Exception):139        from findata_core.redact import redact140        log.error("unhandled", error=redact(repr(exc), s.secret_values()), path=request.url.path)141        return JSONResponse({"error": {"code": "internal_error", "message": "Internal error", "request_id": getattr(request.state, "request_id", None)}}, status_code=500)142143    for r in (auth.router, catalog.router, instruments.router, market.router, fundamentals.router, query.router, exports.router,144              coverage.router, quality.router, health.router, admin.router, assistant.router, public.router):145        app.include_router(r, prefix=API_PREFIX)146147    @app.get("/metrics", include_in_schema=False)148    def metrics(request: Request, p: A.Principal = Depends(A.get_principal)):149        host = A.client_ip(request) or ""150        private = host in ("127.0.0.1", "::1") or host.startswith(("192.168.", "10.67.", "10.", "172.16."))151        if not private and not p.has("admin"):152            return JSONResponse({"error": {"code": "forbidden", "message": "metrics require admin or a private network"}}, status_code=403)153        from findata_api.health import cached_evaluate as evaluate154        evaluate()155        body, ctype = M.render()156        return Response(body, media_type=ctype)157158    @app.on_event("startup")159    def _warm() -> None:160        from findata_api.health import start_refresher as _h161        from findata_api.routers.public import start_refresher as _p162        _h()163        _p()164165    web = s.web_dist or Path(s.root) / "apps" / "web" / "out"166    if web.exists():167        app.mount("/", StaticFiles(directory=str(web), html=True), name="web")168        log.info("web.mounted", path=str(web))169    else:170        @app.get("/", include_in_schema=False)171        def root():172            return {"service": "FinData OS", "docs": "/api/docs", "note": "web console not built"}173    return app174175176app = create_app()177
Files · main
Menu

Theme