Files
geointel/backend/app/main.py
T
Codex 20829f1a24
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
fix(map): make area analysis scale-aware
2026-07-21 21:59:56 +02:00

223 lines
7.9 KiB
Python

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",
)