from __future__ import annotations import logging import re import time import uuid from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from app.api.routes import analysis, areas, assistant, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation, selection_partitions, temporal from app.core.config import get_settings from app.core.errors import AppError from app.core.logging import configure_logging from app.core.request_context import reset_request_id, set_request_id from app.db.session import SessionLocal from app.services.runtime_reconciliation_service import RuntimeReconciliationService logger = logging.getLogger("geointel") SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") UNSAFE_HOST = re.compile(r"[/\\@\s\x00-\x1f\x7f]") def _to_error_payload( code: str, message: str, details: dict | list | None = None, request_id: str | None = None, ) -> dict: return { "error": code, "message": message, "details": details or {}, "request_id": request_id, } def create_app() -> FastAPI: settings = get_settings() configure_logging(settings.log_level, settings.sql_log_level) @asynccontextmanager async def lifespan(_: FastAPI): if settings.reconcile_interrupted_runs_on_startup: db = SessionLocal() try: result = RuntimeReconciliationService.reconcile(db) logger.info( "Runtime reconciliation completed: jobs=%s analysis_runs=%s", result.interrupted_jobs, result.interrupted_analysis_runs, ) except Exception: db.rollback() logger.exception("Runtime reconciliation failed") raise finally: db.close() yield app = FastAPI( title="GeoIntel", version=settings.app_version, docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_methods=["*"], allow_headers=["*"], allow_credentials=True, ) app.include_router(health.router) app.include_router(analysis.router, prefix=settings.api_prefix) app.include_router(projects.router, prefix=settings.api_prefix) app.include_router(areas.router, prefix=settings.api_prefix) app.include_router(datasets.router, prefix=settings.api_prefix) app.include_router(jobs.router, prefix=settings.api_prefix) app.include_router(quality_checks.router, prefix=settings.api_prefix) app.include_router(exports.router, prefix=settings.api_prefix) app.include_router(external.router, prefix=settings.api_prefix) app.include_router(demo.router, prefix=settings.api_prefix) app.include_router(qa.router, prefix=settings.api_prefix) app.include_router(detection.router, prefix=settings.api_prefix) app.include_router(segmentation.router, prefix=settings.api_prefix) app.include_router(selection_partitions.router, prefix=settings.api_prefix) app.include_router(temporal.router, prefix=settings.api_prefix) app.include_router(assistant.router, prefix=settings.api_prefix) @app.middleware("http") async def request_identity(request: Request, call_next): supplied_request_id = request.headers.get("x-request-id", "") request_id = supplied_request_id if SAFE_REQUEST_ID.fullmatch(supplied_request_id) else str(uuid.uuid4()) request.state.request_id = request_id token = set_request_id(request_id) started_at = time.perf_counter() raw_path = str(request.scope.get("path") or "") try: host = request.headers.get("host", "") content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() if not raw_path.startswith("/") or not host or UNSAFE_HOST.search(host): response = JSONResponse( status_code=400, content=_to_error_payload( "INVALID_REQUEST_TARGET", "The request target or Host header is invalid", request_id=request_id, ), ) response.headers["x-request-id"] = request_id return response if content_type == "application/x-www-form-urlencoded": response = JSONResponse( status_code=415, content=_to_error_payload( "UNSUPPORTED_CONTENT_TYPE", "URL-encoded form bodies are not supported", request_id=request_id, ), ) response.headers["x-request-id"] = request_id return response response = await call_next(request) response.headers["x-request-id"] = request_id logger.info( "request_complete request_id=%s method=%s path=%s status=%s duration_ms=%.1f", request_id, request.method, raw_path, response.status_code, (time.perf_counter() - started_at) * 1000, ) return response finally: reset_request_id(token) @app.exception_handler(AppError) async def app_error(request: Request, exc: AppError): # noqa: ARG001 return JSONResponse( status_code=exc.status_code, content=_to_error_payload( exc.code, exc.message, exc.details, request_id=request.state.request_id, ), ) @app.exception_handler(HTTPException) async def http_error(request: Request, exc: HTTPException): # noqa: ARG001 code = "HTTP_ERROR" message = str(exc.detail) details = {} if isinstance(exc.detail, dict): code = str(exc.detail.get("error") or exc.detail.get("code") or code) message = str(exc.detail.get("message") or message) raw_details = exc.detail.get("details") details = raw_details if isinstance(raw_details, (dict, list)) else {} return JSONResponse( status_code=exc.status_code, content=_to_error_payload( code, message, details, request_id=request.state.request_id, ), ) @app.exception_handler(RequestValidationError) async def validation_error(request: Request, exc: RequestValidationError): # noqa: ARG001 return JSONResponse( status_code=422, content=_to_error_payload( "VALIDATION_ERROR", "Validation failed", exc.errors(), request_id=request.state.request_id, ), ) @app.exception_handler(Exception) async def unexpected_error(request: Request, exc: Exception): logger.exception( "Unhandled request error request_id=%s method=%s path=%s", request.state.request_id, request.method, str(request.scope.get("path") or ""), ) return JSONResponse( status_code=500, content=_to_error_payload( "INTERNAL_ERROR", "Unexpected server error", {"type": exc.__class__.__name__}, request_id=request.state.request_id, ), ) return app app = create_app() def main() -> None: import uvicorn settings = get_settings() uvicorn.run( "app.main:app", host="0.0.0.0", port=8000, reload=settings.app_env == "development", )