2025-11-21 12:00:00 +00:00
|
|
|
"""FastAPI application entry point."""
|
|
|
|
|
|
2026-01-07 20:51:13 -05:00
|
|
|
import logging
|
|
|
|
|
import time
|
2025-11-21 12:00:00 +00:00
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
from typing import AsyncGenerator
|
|
|
|
|
|
2026-01-07 20:51:13 -05:00
|
|
|
from fastapi import FastAPI, Request, status
|
|
|
|
|
from fastapi.encoders import jsonable_encoder
|
|
|
|
|
from fastapi.exceptions import RequestValidationError
|
2025-12-29 09:55:30 +00:00
|
|
|
from fastapi.openapi.utils import get_openapi
|
2026-01-07 20:51:13 -05:00
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
2025-11-21 12:00:00 +00:00
|
|
|
|
2026-01-03 10:18:21 +00:00
|
|
|
from app.api.v1 import auth, health, incidents, org
|
2025-11-21 12:00:00 +00:00
|
|
|
from app.config import settings
|
2026-01-07 20:51:13 -05:00
|
|
|
from app.core.logging import setup_logging
|
|
|
|
|
from app.core.telemetry import (
|
|
|
|
|
get_current_trace_id,
|
|
|
|
|
record_exception,
|
|
|
|
|
setup_telemetry,
|
|
|
|
|
shutdown_telemetry,
|
|
|
|
|
)
|
|
|
|
|
from app.db import db
|
|
|
|
|
from app.schemas.common import ErrorDetail, ErrorResponse
|
|
|
|
|
from app.taskqueue import task_queue
|
|
|
|
|
|
|
|
|
|
# Initialize logging before anything else
|
|
|
|
|
setup_logging()
|
|
|
|
|
logger = logging.getLogger(__name__)
|
2025-11-21 12:00:00 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
|
|
|
"""Manage application lifecycle - connect/disconnect resources."""
|
|
|
|
|
# Startup
|
2026-01-07 20:51:13 -05:00
|
|
|
logger.info("Starting IncidentOps API")
|
2025-11-21 12:00:00 +00:00
|
|
|
await db.connect(settings.database_url)
|
2026-01-07 20:51:13 -05:00
|
|
|
await task_queue.startup()
|
|
|
|
|
logger.info("Startup complete")
|
2025-11-21 12:00:00 +00:00
|
|
|
yield
|
|
|
|
|
# Shutdown
|
2026-01-07 20:51:13 -05:00
|
|
|
logger.info("Shutting down IncidentOps API")
|
|
|
|
|
await task_queue.shutdown()
|
2025-11-21 12:00:00 +00:00
|
|
|
await db.disconnect()
|
2026-01-07 20:51:13 -05:00
|
|
|
await shutdown_telemetry()
|
|
|
|
|
logger.info("Shutdown complete")
|
2025-11-21 12:00:00 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI(
|
|
|
|
|
title="IncidentOps",
|
|
|
|
|
description="Incident management API with multi-tenant org support",
|
|
|
|
|
version="0.1.0",
|
2025-12-29 09:55:30 +00:00
|
|
|
docs_url="/docs",
|
|
|
|
|
redoc_url="/redoc",
|
|
|
|
|
openapi_url="/openapi.json",
|
2025-11-21 12:00:00 +00:00
|
|
|
lifespan=lifespan,
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-07 20:51:13 -05:00
|
|
|
# Set up OpenTelemetry instrumentation
|
|
|
|
|
setup_telemetry(app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.middleware("http")
|
|
|
|
|
async def request_logging_middleware(request: Request, call_next):
|
|
|
|
|
start = time.time()
|
|
|
|
|
response = await call_next(request)
|
|
|
|
|
duration_ms = (time.time() - start) * 1000
|
|
|
|
|
logger.info(
|
|
|
|
|
"request",
|
|
|
|
|
extra={
|
|
|
|
|
"method": request.method,
|
|
|
|
|
"path": request.url.path,
|
|
|
|
|
"status_code": response.status_code,
|
|
|
|
|
"duration_ms": round(duration_ms, 2),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return response
|
|
|
|
|
|
2025-12-29 09:55:30 +00:00
|
|
|
app.openapi_tags = [
|
|
|
|
|
{"name": "auth", "description": "Registration, login, token lifecycle"},
|
2026-01-03 10:18:21 +00:00
|
|
|
{"name": "org", "description": "Organization membership, services, and notifications"},
|
|
|
|
|
{"name": "incidents", "description": "Incident lifecycle and timelines"},
|
2025-12-29 09:55:30 +00:00
|
|
|
{"name": "health", "description": "Service health probes"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-01-07 20:51:13 -05:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Global Exception Handlers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_error_response(
|
|
|
|
|
error: str,
|
|
|
|
|
message: str,
|
|
|
|
|
status_code: int,
|
|
|
|
|
details: list[ErrorDetail] | None = None,
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
"""Build a structured error response with trace context."""
|
|
|
|
|
response = ErrorResponse(
|
|
|
|
|
error=error,
|
|
|
|
|
message=message,
|
|
|
|
|
details=details,
|
|
|
|
|
request_id=get_current_trace_id(),
|
|
|
|
|
)
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
content=jsonable_encoder(response),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
|
|
|
async def http_exception_handler(
|
|
|
|
|
request: Request, exc: StarletteHTTPException
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
"""Handle HTTP exceptions with structured error responses."""
|
|
|
|
|
# Map status codes to error type strings
|
|
|
|
|
error_types = {
|
|
|
|
|
400: "bad_request",
|
|
|
|
|
401: "unauthorized",
|
|
|
|
|
403: "forbidden",
|
|
|
|
|
404: "not_found",
|
|
|
|
|
409: "conflict",
|
|
|
|
|
422: "validation_error",
|
|
|
|
|
429: "rate_limited",
|
|
|
|
|
500: "internal_error",
|
|
|
|
|
502: "bad_gateway",
|
|
|
|
|
503: "service_unavailable",
|
|
|
|
|
}
|
|
|
|
|
error_type = error_types.get(exc.status_code, "error")
|
|
|
|
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
"HTTP exception",
|
|
|
|
|
extra={
|
|
|
|
|
"status_code": exc.status_code,
|
|
|
|
|
"error": error_type,
|
|
|
|
|
"detail": exc.detail,
|
|
|
|
|
"path": str(request.url.path),
|
|
|
|
|
"method": request.method,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return _build_error_response(
|
|
|
|
|
error=error_type,
|
|
|
|
|
message=str(exc.detail),
|
|
|
|
|
status_code=exc.status_code,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
|
|
|
async def validation_exception_handler(
|
|
|
|
|
request: Request, exc: RequestValidationError
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
"""Handle Pydantic validation errors with detailed error responses."""
|
|
|
|
|
details = [
|
|
|
|
|
ErrorDetail(
|
|
|
|
|
loc=[str(loc) for loc in error["loc"]],
|
|
|
|
|
msg=error["msg"],
|
|
|
|
|
type=error["type"],
|
|
|
|
|
)
|
|
|
|
|
for error in exc.errors()
|
|
|
|
|
]
|
2025-12-29 09:55:30 +00:00
|
|
|
|
2026-01-07 20:51:13 -05:00
|
|
|
logger.warning(
|
|
|
|
|
"Validation error",
|
|
|
|
|
extra={
|
|
|
|
|
"path": str(request.url.path),
|
|
|
|
|
"method": request.method,
|
|
|
|
|
"error_count": len(details),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return _build_error_response(
|
|
|
|
|
error="validation_error",
|
|
|
|
|
message="Request validation failed",
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
|
|
|
details=details,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
|
|
|
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
|
|
|
"""Handle unexpected exceptions with logging and safe error response."""
|
|
|
|
|
# Record exception in the current span for tracing
|
|
|
|
|
record_exception(exc)
|
|
|
|
|
|
|
|
|
|
logger.exception(
|
|
|
|
|
"Unhandled exception",
|
|
|
|
|
extra={
|
|
|
|
|
"path": str(request.url.path),
|
|
|
|
|
"method": request.method,
|
|
|
|
|
"exception_type": type(exc).__name__,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Don't leak internal error details in production
|
|
|
|
|
message = "An unexpected error occurred"
|
|
|
|
|
if settings.debug:
|
|
|
|
|
message = f"{type(exc).__name__}: {exc}"
|
|
|
|
|
|
|
|
|
|
return _build_error_response(
|
|
|
|
|
error="internal_error",
|
|
|
|
|
message=message,
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# OpenAPI Customization
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def custom_openapi() -> dict:
|
|
|
|
|
"""Add JWT bearer security scheme and error responses to OpenAPI schema."""
|
2025-12-29 09:55:30 +00:00
|
|
|
if app.openapi_schema:
|
|
|
|
|
return app.openapi_schema
|
|
|
|
|
|
|
|
|
|
openapi_schema = get_openapi(
|
|
|
|
|
title=app.title,
|
|
|
|
|
version=app.version,
|
|
|
|
|
description=app.description,
|
|
|
|
|
routes=app.routes,
|
2026-01-07 20:51:13 -05:00
|
|
|
tags=app.openapi_tags,
|
2025-12-29 09:55:30 +00:00
|
|
|
)
|
2026-01-07 20:51:13 -05:00
|
|
|
|
|
|
|
|
# Add security schemes
|
|
|
|
|
components = openapi_schema.setdefault("components", {})
|
|
|
|
|
security_schemes = components.setdefault("securitySchemes", {})
|
2025-12-29 09:55:30 +00:00
|
|
|
security_schemes["BearerToken"] = {
|
|
|
|
|
"type": "http",
|
|
|
|
|
"scheme": "bearer",
|
|
|
|
|
"bearerFormat": "JWT",
|
|
|
|
|
"description": "Paste the JWT access token returned by /auth endpoints",
|
|
|
|
|
}
|
|
|
|
|
openapi_schema["security"] = [{"BearerToken": []}]
|
2026-01-07 20:51:13 -05:00
|
|
|
|
|
|
|
|
# Add common error response schemas
|
|
|
|
|
schemas = components.setdefault("schemas", {})
|
|
|
|
|
schemas["ErrorResponse"] = {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {
|
|
|
|
|
"error": {"type": "string", "description": "Error type identifier"},
|
|
|
|
|
"message": {"type": "string", "description": "Human-readable error message"},
|
|
|
|
|
"details": {
|
|
|
|
|
"type": "array",
|
|
|
|
|
"items": {"$ref": "#/components/schemas/ErrorDetail"},
|
|
|
|
|
"nullable": True,
|
|
|
|
|
"description": "Validation error details",
|
|
|
|
|
},
|
|
|
|
|
"request_id": {
|
|
|
|
|
"type": "string",
|
|
|
|
|
"nullable": True,
|
|
|
|
|
"description": "Trace ID for debugging",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"required": ["error", "message"],
|
|
|
|
|
}
|
|
|
|
|
schemas["ErrorDetail"] = {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {
|
|
|
|
|
"loc": {
|
|
|
|
|
"type": "array",
|
|
|
|
|
"items": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
|
|
|
|
|
"description": "Error location path",
|
|
|
|
|
},
|
|
|
|
|
"msg": {"type": "string", "description": "Error message"},
|
|
|
|
|
"type": {"type": "string", "description": "Error type"},
|
|
|
|
|
},
|
|
|
|
|
"required": ["loc", "msg", "type"],
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 09:55:30 +00:00
|
|
|
app.openapi_schema = openapi_schema
|
|
|
|
|
return app.openapi_schema
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.openapi = custom_openapi # type: ignore[assignment]
|
|
|
|
|
|
2025-11-21 12:00:00 +00:00
|
|
|
# Include routers
|
2025-12-29 09:55:30 +00:00
|
|
|
app.include_router(auth.router, prefix=settings.api_v1_prefix)
|
2026-01-03 10:18:21 +00:00
|
|
|
app.include_router(incidents.router, prefix=settings.api_v1_prefix)
|
|
|
|
|
app.include_router(org.router, prefix=settings.api_v1_prefix)
|
2025-11-21 12:00:00 +00:00
|
|
|
app.include_router(health.router, prefix=settings.api_v1_prefix, tags=["health"])
|