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>
This commit is contained in:
133
backend/app/main.py
Normal file
133
backend/app/main.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
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/"}
|
||||
Reference in New Issue
Block a user