223 lines
7.9 KiB
Python
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",
|
|
)
|