143 lines
5.7 KiB
Python
143 lines
5.7 KiB
Python
"""
|
|
FastAPI application entry point.
|
|
|
|
Run with:
|
|
uvicorn app.main:app --reload --port 8000
|
|
|
|
(see backend/README.md / the project documentation for full setup steps)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import FileResponse
|
|
|
|
from app.infrastructure.settings import API_CORS_ORIGINS
|
|
from app.api.routers import health, brands, search, chat, catalog, system
|
|
from app.api.routers import stores, discounts, analytics as store_analytics, trending, recommendations, store_admin
|
|
from app.api.routers import nutrition, nutrition_admin, upload
|
|
from app.api.routers import auth, user_products, admin_train
|
|
from app.services.store_db import ensure_store_intelligence_schema
|
|
from app.services.nutrition_db import ensure_nutrition_schema
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI):
|
|
"""
|
|
Schema checks and auto-seeding, kicked off without blocking startup.
|
|
|
|
The work runs on a daemon thread rather than being awaited: it touches
|
|
Postgres, which may be slow or briefly unreachable on a cold boot, and the
|
|
server answering /api/health in under a second is what lets the container
|
|
healthcheck pass while that settles.
|
|
"""
|
|
|
|
def _async_init():
|
|
try:
|
|
ensure_store_intelligence_schema()
|
|
ensure_nutrition_schema()
|
|
from app.api.routers.system import _run_background_auto_seed
|
|
_run_background_auto_seed()
|
|
except Exception as e:
|
|
logger.warning("Startup background init warning: %s", e)
|
|
|
|
threading.Thread(target=_async_init, daemon=True).start()
|
|
yield
|
|
|
|
|
|
app = FastAPI(
|
|
title="Brand Product Search Engine - RAG API",
|
|
description=(
|
|
"Local, CPU-only RAG API over an Indian FMCG product catalog stored in pgvector. "
|
|
"Includes automated multi-store intelligence, ML discount engines, and nutrition intelligence."
|
|
),
|
|
version="3.2.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
# When the app is served same-origin (the frontend's nginx proxies /api/* to
|
|
# this service), API_CORS_ORIGINS is empty and no cross-origin request is ever
|
|
# made. It is populated only when the API is also published on its own host -
|
|
# see api.{$DOMAIN} in the Caddyfile.
|
|
#
|
|
# A wildcard origin and credentialed requests are mutually exclusive under the
|
|
# CORS spec: browsers reject `Access-Control-Allow-Origin: *` on any request
|
|
# carrying credentials. Sending both is a silent misconfiguration - the server
|
|
# looks configured while every browser call fails - so a wildcard turns
|
|
# credentials off explicitly and says so in the log.
|
|
_allow_credentials = "*" not in API_CORS_ORIGINS
|
|
if not _allow_credentials:
|
|
logger.warning(
|
|
"API_CORS_ORIGINS contains '*': disabling allow_credentials, because "
|
|
"browsers reject credentialed cross-origin requests to a wildcard origin. "
|
|
"List the exact origins instead if you need cookies or Authorization headers."
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=API_CORS_ORIGINS,
|
|
allow_credentials=_allow_credentials,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
app.include_router(health.router, prefix="/api")
|
|
app.include_router(auth.router, prefix="/api")
|
|
app.include_router(user_products.router, prefix="/api")
|
|
app.include_router(admin_train.router, prefix="/api")
|
|
app.include_router(system.router, prefix="/api")
|
|
app.include_router(brands.router, prefix="/api")
|
|
app.include_router(search.router, prefix="/api")
|
|
app.include_router(chat.router, prefix="/api")
|
|
app.include_router(catalog.router, prefix="/api")
|
|
app.include_router(stores.router, prefix="/api")
|
|
app.include_router(discounts.router, prefix="/api")
|
|
app.include_router(store_analytics.router, prefix="/api")
|
|
app.include_router(trending.router, prefix="/api")
|
|
app.include_router(recommendations.router, prefix="/api")
|
|
app.include_router(store_admin.router, prefix="/api")
|
|
app.include_router(nutrition.router, prefix="/api")
|
|
app.include_router(nutrition_admin.router, prefix="/api")
|
|
app.include_router(upload.router, prefix="/api")
|
|
|
|
# Serve built frontend static files if dist exists (single-port unified deployment)
|
|
FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
|
|
if FRONTEND_DIST.exists() and (FRONTEND_DIST / "assets").exists():
|
|
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets")
|
|
|
|
@app.get("/{full_path:path}")
|
|
def serve_frontend(full_path: str):
|
|
# API and docs paths are served by the routers registered above, so
|
|
# this catch-all only sees them when the path genuinely doesn't exist.
|
|
# Returning None there would answer 200 with a `null` body - an unknown
|
|
# endpoint would look like a successful call to any client. 404 is the
|
|
# honest answer, and it matters now that the API is also reachable
|
|
# directly at api.{$DOMAIN} rather than only behind the frontend.
|
|
if full_path.startswith(("api", "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": "Brand Product Search Engine - RAG API",
|
|
"docs": "/docs",
|
|
"health": "/api/health",
|
|
"system_status": "/api/system/status",
|
|
}
|