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, auth, 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 from app.services.auth_service import AuthService 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(auth.router, prefix=settings.api_prefix) 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 public_auth_paths = { f"{settings.api_prefix}/auth/session", f"{settings.api_prefix}/auth/login", f"{settings.api_prefix}/auth/logout", } direct_loopback_request = ( request.client is not None and request.client.host in {"127.0.0.1", "::1"} and not request.headers.get("x-real-ip") and not request.headers.get("x-forwarded-for") ) if ( settings.auth_enabled and raw_path.startswith(f"{settings.api_prefix}/") and raw_path not in public_auth_paths and not direct_loopback_request ): principal = AuthService.verify_session_token( request.cookies.get(auth.COOKIE_NAME), settings, ) if principal is None: response = JSONResponse( status_code=401, content=_to_error_payload( "AUTHENTICATION_REQUIRED", "Meld u aan om de GeoIntel API te gebruiken.", request_id=request_id, ), ) response.headers["x-request-id"] = request_id return response request.state.auth_principal = principal 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", )