Files
sriram c7e4d59188 Electronics Catalog: API, MCP server, frontend and deployment
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>
2026-10-01 12:17:42 +05:30

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/"}