Spaces:
Running
Running
| """FastAPI application factory. | |
| The ``create_app`` function is the single entry point used by the | |
| production server (``main.py``) and the test suite. Building the app | |
| inside a factory rather than a module-level ``app = FastAPI()`` makes | |
| two things easier: | |
| 1. **Per-test isolation.** Each test can build its own app with a | |
| patched cache / config, avoiding cross-test bleed. | |
| 2. **Future config injection.** When deployment grows env-driven | |
| feature flags (e.g. enable SSE optimisation, mount alternate model | |
| paths), they all funnel through ``get_settings()`` and the factory. | |
| Static frontend mount (Docker / hosted deploy) | |
| ---------------------------------------------- | |
| When the env var ``ROVERDEVKIT_STATIC_DIR`` is set and points at an | |
| existing directory containing the production frontend bundle | |
| (``index.html`` + Vite-emitted assets), the factory mounts that | |
| directory at the application root with HTML history-mode fallback. | |
| This lets a single uvicorn process serve both the API (``/api`` prefix | |
| not used today) and the React SPA -- the deploy story for Docker / | |
| HF Spaces / Fly.io. Local dev leaves the var unset; the Vite dev | |
| server handles the SPA on :5173 and proxies API calls to :8000. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from pathlib import Path | |
| from fastapi import FastAPI | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from webapp.backend.config import Settings, get_settings | |
| from webapp.backend.routes import evaluate as evaluate_routes | |
| from webapp.backend.routes import health as health_routes | |
| from webapp.backend.routes import optimize as optimize_routes | |
| from webapp.backend.routes import predict as predict_routes | |
| from webapp.backend.routes import registry as registry_routes | |
| from webapp.backend.routes import scenarios as scenarios_routes | |
| from webapp.backend.routes import shap as shap_routes | |
| from webapp.backend.routes import sweep as sweep_routes | |
| logger = logging.getLogger(__name__) | |
| API_TITLE = "roverdevkit tradespace API" | |
| API_DESCRIPTION = ( | |
| "Backend for the webapp interactive tradespace exploration tool. " | |
| "Wraps the corrected mission evaluator and the quantile-calibration " | |
| "quantile-XGBoost surrogate." | |
| ) | |
| def create_app(settings: Settings | None = None) -> FastAPI: | |
| """Build and return the FastAPI app. | |
| Parameters | |
| ---------- | |
| settings | |
| Optional override; falls back to :func:`get_settings`. Tests | |
| pass a custom ``Settings`` to point at fixture artifacts; the | |
| server entry point uses the env-driven default. | |
| """ | |
| cfg = settings or get_settings() | |
| app = FastAPI( | |
| title=API_TITLE, | |
| description=API_DESCRIPTION, | |
| version="0.1.0", | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=list(cfg.cors_origins), | |
| allow_credentials=True, | |
| allow_methods=["GET", "POST"], | |
| allow_headers=["*"], | |
| ) | |
| app.include_router(health_routes.router) | |
| app.include_router(scenarios_routes.router) | |
| app.include_router(registry_routes.router) | |
| app.include_router(predict_routes.router) | |
| app.include_router(evaluate_routes.router) | |
| app.include_router(sweep_routes.router) | |
| app.include_router(optimize_routes.router) | |
| app.include_router(shap_routes.router) | |
| _maybe_mount_frontend(app) | |
| logger.info( | |
| "FastAPI app built (artifacts_present=%s, dataset_version=%s)", | |
| cfg.artifacts_present, | |
| cfg.dataset_version, | |
| ) | |
| return app | |
| def _maybe_mount_frontend(app: FastAPI) -> None: | |
| """Mount the production frontend bundle at ``/`` when configured. | |
| Activated by setting ``ROVERDEVKIT_STATIC_DIR`` to a directory | |
| that contains a built Vite bundle (``index.html`` + ``assets/``). | |
| Wires up: | |
| - ``/assets/*`` for the hashed JS / CSS / image bundles served | |
| directly by :class:`StaticFiles` with caching headers, | |
| - ``/`` for the SPA entry point, plus a catch-all rewrite that | |
| hands any non-API path back to ``index.html`` so the React | |
| router's history-mode URLs survive a hard refresh. | |
| API routes are registered first (above) so they always win over | |
| the catch-all. We only register fallthrough handlers when the | |
| static dir actually exists; tests and `make webapp-dev` leave | |
| the env var unset and skip this branch. | |
| """ | |
| static_dir_env = os.environ.get("ROVERDEVKIT_STATIC_DIR") | |
| if not static_dir_env: | |
| return | |
| static_dir = Path(static_dir_env).expanduser().resolve() | |
| index_path = static_dir / "index.html" | |
| if not index_path.is_file(): | |
| logger.warning( | |
| "ROVERDEVKIT_STATIC_DIR=%s but %s is missing; skipping frontend mount", | |
| static_dir, | |
| index_path, | |
| ) | |
| return | |
| assets_dir = static_dir / "assets" | |
| if assets_dir.is_dir(): | |
| app.mount( | |
| "/assets", | |
| StaticFiles(directory=str(assets_dir)), | |
| name="frontend-assets", | |
| ) | |
| def _serve_index_root() -> FileResponse: | |
| return FileResponse(index_path) | |
| def _spa_fallback(path: str) -> FileResponse: | |
| candidate = (static_dir / path).resolve() | |
| # Reject path-traversal escapes; serve the file directly when | |
| # it exists (e.g. /favicon.ico, /robots.txt). Anything else | |
| # is treated as a SPA route and re-served as index.html so | |
| # the React router can pick it up on the client. | |
| try: | |
| candidate.relative_to(static_dir) | |
| except ValueError: | |
| return FileResponse(index_path) | |
| if candidate.is_file(): | |
| return FileResponse(candidate) | |
| return FileResponse(index_path) | |
| logger.info("mounted frontend static bundle from %s", static_dir) | |