175 lines
5.1 KiB
Python
175 lines
5.1 KiB
Python
"""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.",
|
|
}
|