""" FastAPI application entry point for the Electronics Catalog (local only). Run with (from backend/): .venv\\Scripts\\python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 """ from __future__ import annotations import logging import os import threading from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException, Request from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from app.api.routers import auth, elec, elec_admin, health from app.infrastructure.security import auth_config_summary from app.infrastructure.settings import API_CORS_ORIGINS, DB_HOST, DB_NAME, DB_PORT, cleaned_env_names from app.mcp_server import mcp from fastmcp.utilities.lifespan import combine_lifespans logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) def _init_schema() -> None: """Apply pending migrations and make sure reference data exists. Runs on a thread so the API answers /api/health even while Postgres is starting.""" try: from app.electronics.db import repository as repo from app.electronics.db.migrate import run_migrations from app.electronics.reference import load_reference applied = run_migrations() if applied: logger.info("Applied migrations: %s", ", ".join(applied)) repo.seed_reference(load_reference()) except Exception as exc: # noqa: BLE001 - the health endpoint reports the DB state logger.warning("Schema initialisation skipped: %s", exc) @asynccontextmanager async def lifespan(_app: FastAPI): logger.info("Electronics Catalog using database %s at %s:%s", DB_NAME, DB_HOST, DB_PORT) threading.Thread(target=_init_schema, daemon=True).start() yield # MCP endpoint (FastMCP) for AI assistants, served at /mcp/ by this same app. # Its session manager must start with the app, hence the combined lifespan. mcp_app = mcp.http_app(path="/") app = FastAPI( title="Electronics Catalog API", description=( "Local, evidence-backed catalogue of electronics sold in India (Tamil Nadu focus). " "Products, prices, availability and images are collected from real retail listings " "found through web search; nothing is generated." ), version="1.0.0", lifespan=combine_lifespans(lifespan, mcp_app.lifespan), ) _allow_credentials = "*" not in API_CORS_ORIGINS app.add_middleware( CORSMiddleware, allow_origins=API_CORS_ORIGINS, allow_credentials=_allow_credentials, allow_methods=["*"], allow_headers=["*"], ) def _json_safe(value): if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))): return str(value) if isinstance(value, dict): return {k: _json_safe(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [_json_safe(v) for v in value] return value @app.exception_handler(RequestValidationError) async def _validation_error_as_422(request: Request, exc: RequestValidationError) -> JSONResponse: return JSONResponse(status_code=422, content={"detail": _json_safe(jsonable_encoder(exc.errors()))}) _auth_cfg = auth_config_summary() logger.info( "Auth config: enabled=%s allow_any_login=%s admin_username=%r hash_valid=%s", _auth_cfg["enabled"], _auth_cfg["allow_any_login"], _auth_cfg["admin_username"], _auth_cfg["password_hash_valid"], ) if _auth_cfg["allow_any_login"]: logger.warning("AUTH_ALLOW_ANY_LOGIN=true: any password is accepted. Keep the API on 127.0.0.1.") if cleaned_env_names(): logger.warning("Settings arrived quoted or padded and were cleaned: %s", ", ".join(cleaned_env_names())) app.include_router(health.router, prefix="/api") app.include_router(auth.router, prefix="/api") app.include_router(elec.router, prefix="/api") app.include_router(elec_admin.router, prefix="/api") app.mount("/mcp", mcp_app) _dist_override = os.getenv("FRONTEND_DIST_DIR", "").strip() FRONTEND_DIST = Path(_dist_override) if _dist_override else Path(__file__).resolve().parents[2] / "frontend" / "dist" if (FRONTEND_DIST / "assets").exists(): logger.info("Serving built frontend from %s", FRONTEND_DIST) app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets") @app.get("/{full_path:path}") def serve_frontend(full_path: str): if full_path.startswith(("api", "mcp", "docs", "redoc", "openapi.json")): raise HTTPException(status_code=404, detail="Not found") file_path = FRONTEND_DIST / full_path if file_path.exists() and file_path.is_file(): return FileResponse(file_path) return FileResponse(FRONTEND_DIST / "index.html") else: @app.get("/") def root() -> dict: return {"service": "Electronics Catalog API", "docs": "/docs", "health": "/api/health", "mcp": "/mcp/"}