File size: 4,844 Bytes
215f97f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
"""

FastAPI application entrypoint.



Registers all routers and manages the DB connection lifecycle.

"""

from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.core.config import settings
from app.core.logging import setup_logging, get_logger
from app.core.firebase import init_firebase
from app.api.routes import classify, model_status, train

from fastapi.responses import RedirectResponse

logger = get_logger(__name__)


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Application lifespan — startup and shutdown hooks."""
    # Startup
    setup_logging()
    logger.info("Starting ML service…")

    # Initialize Firebase Admin SDK
    init_firebase()

    # Warm-load model (fetches active model from Firebase Storage/Firestore or uses DummyModel fallback)
    try:
        from app.ml.serving.registry import load_active_model

        model = await load_active_model()
        logger.info("Active model initialized successfully (cached)")
    except Exception as e:
        logger.warning("Active model initialization warning: %s", str(e))

    logger.info("==================================================================")
    logger.info("🚀 Swagger UI (Interactive API Docs): http://localhost:8000/api-docs")
    logger.info("==================================================================")

    yield

    # Shutdown
    logger.info("ML service shut down")


app = FastAPI(
    title="MyGuard ML Service",
    description=(
        "Internal RETVec+CNN classification service. "
        "Called server-to-server by the Node.js backend — not exposed to end users."
    ),
    version="0.1.0",
    lifespan=lifespan,
    docs_url="/api-docs",
    redoc_url="/redoc",
)

# CORS Middleware (Restricts origins to Render backend + Swagger UI / Localhost testing)
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.ALLOWED_ORIGINS_LIST,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Register routers
app.include_router(classify.router)
app.include_router(model_status.router)
app.include_router(train.router)


from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc: RequestValidationError):
    """Format Pydantic validation errors into clean {code, message} JSON."""
    msg_parts = []
    for err in exc.errors():
        loc = ".".join(str(l) for l in err.get("loc", []) if str(l) != "body")
        msg = err.get("msg", "Invalid field")
        msg_parts.append(f"Field '{loc}' {msg.lower()}" if loc else msg)
    message = "; ".join(msg_parts) if msg_parts else "Unprocessable Entity validation error"

    return JSONResponse(
        status_code=422,
        content={
            "code": "UNPROCESSABLE_ENTITY",
            "message": message,
        },
    )


@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc: StarletteHTTPException):
    """Format HTTP exceptions into clean {code, message} JSON."""
    detail = exc.detail
    if isinstance(detail, dict):
        message = detail.get("error") or detail.get("message") or detail.get("detail") or str(detail)
    else:
        message = str(detail)

    code_map = {
        400: "BAD_REQUEST",
        401: "UNAUTHORIZED",
        403: "FORBIDDEN",
        404: "NOT_FOUND",
        422: "UNPROCESSABLE_ENTITY",
        500: "INTERNAL_SERVER_ERROR",
        503: "SERVICE_UNAVAILABLE",
    }
    code = code_map.get(exc.status_code, "ERROR")

    return JSONResponse(
        status_code=exc.status_code,
        content={
            "code": code,
            "message": message,
        },
    )


@app.exception_handler(Exception)
async def global_exception_handler(request, exc: Exception):
    """Catch unhandled internal server exceptions to prevent raw 500 server crashes."""
    logger.error("Unhandled server error on %s: %s", request.url.path, str(exc), exc_info=True)
    return JSONResponse(
        status_code=500,
        content={
            "code": "INTERNAL_SERVER_ERROR",
            "message": "An internal server error occurred while processing the request.",
        },
    )



@app.get("/", include_in_schema=False)
async def root():
    """Redirect root path to interactive Swagger UI documentation."""
    return RedirectResponse(url="/api-docs")


@app.get("/health", tags=["Health"])
async def health_check():
    """Simple liveness probe."""
    return {"status": "ok"}