Verified catalogue of mobiles and laptops sold in India, collected from real retail listings (FastAPI backend, React frontend, Postgres/pgvector). - REST API under /api/elec (read-only catalogue; admin endpoints need login) - MCP server (FastMCP) at /mcp/ with list_categories, search_products, get_product and price_history tools - Real ratings and reviews read from product pages and search results - Production Dockerfile (requirements-api.txt, no PyTorch) and .env.production.example; remote database only via an explicit ELEC_ALLOW_REMOTE_DB host/name allowlist - docs/API.md: endpoint and MCP reference with live examples Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
134 lines
5.0 KiB
Python
134 lines
5.0 KiB
Python
"""
|
|
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/"}
|