"""ASF Core — FastAPI application entrypoint. Run locally with: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 """ from __future__ import annotations from contextlib import asynccontextmanager from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from .config import get_settings from .logging_config import setup_logging from .middleware.correlation import CorrelationMiddleware from .db.mongo import mongo_lifespan, create_indexes, close_mongo from .messaging.rabbitmq import ( rabbitmq_lifespan, close_rabbitmq, get_event_publisher, ) from .api.v1 import api_v1_router from .repositories import ( NotFoundError, DuplicateError, TenantMismatchError, ) setup_logging() s = get_settings() @asynccontextmanager async def lifespan(app: FastAPI): # noqa: ARG001 - signature required by FastAPI """Startup / shutdown: ensure indexes, open/close connections.""" import structlog log = structlog.get_logger(__name__) try: await create_indexes() except Exception: # noqa: BLE001 await log.aexception("mongo_indexes_failed") try: await get_event_publisher() except Exception: # noqa: BLE001 # If RabbitMQ isn't reachable yet we still start the API — core # writes Mongo first and events are best-effort during startup. await log.awarning("rabbitmq_not_available_on_startup") yield await close_rabbitmq() await close_mongo() await log.ainfo("asf_core_shutdown") # --------------------------------------------------------------------------- # App object # --------------------------------------------------------------------------- app = FastAPI( title="Azurian Software Factory (ASF) — Core API", description=( "Multi-tenant REST API for Azurian Software Factory. " "Handles Organizations, Projects, Artifacts, Artifact Relationships, " "Jobs, Audit events, and dashboard aggregations. " "All business operations must go through this API — " "never access MongoDB / RabbitMQ directly from the browser." ), version="0.1.0", docs_url="/docs", redoc_url="/redoc", openapi_url="/openapi.json", lifespan=lifespan, ) # --------------------------------------------------------------------------- # Middleware # --------------------------------------------------------------------------- app.add_middleware( CORSMiddleware, allow_origins=s.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=[ "X-Correlation-Id", ], ) # Correlation / context (tenant + user id extraction in dev-trust mode) app.add_middleware(CorrelationMiddleware) # --------------------------------------------------------------------------- # Error handlers — translate repository exceptions to consistent HTTP shapes # --------------------------------------------------------------------------- @app.exception_handler(NotFoundError) async def _not_found(req: Request, exc: NotFoundError) -> JSONResponse: # noqa: ARG001 return JSONResponse( status_code=status.HTTP_404_NOT_FOUND, content={ "error": "not_found", "kind": exc.kind, "entity_id": str(exc.entity_id), "message": str(exc), }, ) @app.exception_handler(DuplicateError) async def _duplicate(req: Request, exc: DuplicateError) -> JSONResponse: # noqa: ARG001 return JSONResponse( status_code=status.HTTP_409_CONFLICT, content={ "error": "duplicate", "kind": exc.kind, "key": exc.key, "message": str(exc), }, ) @app.exception_handler(TenantMismatchError) async def _tenant_mismatch(req: Request, exc: TenantMismatchError) -> JSONResponse: # noqa: ARG001 return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, content={ "error": "tenant_isolation_violation", "kind": exc.kind, "entity_id": str(exc.entity_id), "message": str(exc), }, ) @app.exception_handler(Exception) async def _unhandled(req: Request, exc: Exception) -> JSONResponse: # noqa: ARG001 import structlog await structlog.get_logger(__name__).aexception("unhandled_exception") return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={ "error": "internal_server_error", "message": "An unexpected error occurred. The correlation id has been logged.", }, ) # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- app.include_router(api_v1_router, prefix=s.api_prefix) @app.get("/", tags=["meta"]) async def root() -> dict: return { "name": "asf-core", "version": "0.1.0", "docs": "/docs", "health": f"{s.api_prefix}/health", "tagline": "From Requirement to Production, everything is contextualized, traceable and AI-accessible.", }