Merge branch 'master' of https://gitapp.workolik.com/nearle_daily/catalogue_backend
This commit is contained in:
98
app/main.py
98
app/main.py
@@ -9,6 +9,7 @@ Run with:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
@@ -18,11 +19,12 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.infrastructure.persistence import restore_bundled_assets
|
||||
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.api.routers import auth, user_products, admin_train, mcp_info
|
||||
from app.services.store_db import ensure_store_intelligence_schema
|
||||
from app.services.nutrition_db import ensure_nutrition_schema
|
||||
|
||||
@@ -32,6 +34,18 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from app.mcp_server import MCP_PATH, build_http_app as build_mcp_app
|
||||
|
||||
_mcp_app = build_mcp_app()
|
||||
except Exception as e: # pragma: no cover - depends on an optional dependency
|
||||
# fastmcp needs Python >=3.10. The image is 3.11, but an older local
|
||||
# interpreter should lose the MCP endpoint, not the whole API.
|
||||
logging.getLogger(__name__).warning("MCP server unavailable: %s", e)
|
||||
_mcp_app = None
|
||||
MCP_PATH = "/mcp"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
"""
|
||||
@@ -43,6 +57,15 @@ async def lifespan(_app: FastAPI):
|
||||
healthcheck pass while that settles.
|
||||
"""
|
||||
|
||||
# Runs before the thread below, and synchronously: the seed catalogs and
|
||||
# model artifacts have to be in place before the first request can read
|
||||
# them, and it is a handful of file copies on first boot, nothing on every
|
||||
# boot after that.
|
||||
try:
|
||||
restore_bundled_assets()
|
||||
except Exception as e:
|
||||
logger.warning("Could not restore bundled assets: %s", e)
|
||||
|
||||
def _async_init():
|
||||
try:
|
||||
ensure_store_intelligence_schema()
|
||||
@@ -58,7 +81,17 @@ async def lifespan(_app: FastAPI):
|
||||
logger.warning("Startup background init warning: %s", e)
|
||||
|
||||
threading.Thread(target=_async_init, daemon=True).start()
|
||||
yield
|
||||
|
||||
# The mounted MCP app carries its own lifespan, which starts the session
|
||||
# manager its request handler depends on. Mounting a sub-app does NOT run
|
||||
# that lifespan - only the outermost app's is executed - so without this
|
||||
# the endpoint exists, accepts a connection, and then fails on the first
|
||||
# message with a session manager that was never started.
|
||||
if _mcp_app is not None:
|
||||
async with _mcp_app.lifespan(_mcp_app):
|
||||
yield
|
||||
else:
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -98,6 +131,24 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# A wrong origin list fails only in the browser, as an opaque "blocked by CORS"
|
||||
# with a perfectly healthy 200 in the server log - so state the effective list
|
||||
# at startup, where it can actually be compared against the frontend's URL.
|
||||
logger.info(
|
||||
"CORS allowed origins: %s",
|
||||
", ".join(API_CORS_ORIGINS) if API_CORS_ORIGINS else "(none - same-origin only)",
|
||||
)
|
||||
if API_CORS_ORIGINS and all(
|
||||
o.startswith(("http://localhost", "http://127.0.0.1")) for o in API_CORS_ORIGINS
|
||||
):
|
||||
logger.warning(
|
||||
"API_CORS_ORIGINS lists only localhost origins (%s). If this API is "
|
||||
"published on a domain and the frontend is served from a different one, "
|
||||
"every browser request will be blocked. Set API_CORS_ORIGINS to the "
|
||||
"frontend's exact origin, e.g. https://catalogue.nearle.ai.in",
|
||||
", ".join(API_CORS_ORIGINS),
|
||||
)
|
||||
|
||||
app.include_router(health.router, prefix="/api")
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(user_products.router, prefix="/api")
|
||||
@@ -116,10 +167,44 @@ 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")
|
||||
app.include_router(mcp_info.router, prefix="/api")
|
||||
|
||||
# MCP lives outside /api on purpose: it is a protocol endpoint for AI clients,
|
||||
# not part of the REST surface, and the catch-all SPA route below only skips
|
||||
# paths it recognises. Mounted rather than routed because it is a whole ASGI
|
||||
# app with its own request handling.
|
||||
if _mcp_app is not None:
|
||||
app.mount(MCP_PATH, _mcp_app)
|
||||
logger.info("MCP server mounted at %s", MCP_PATH)
|
||||
|
||||
# Serve built frontend static files if dist exists (single-port unified
|
||||
# deployment). Off in the normal setup: the React app is served by its own
|
||||
# nginx on catalogue.nearle.ai.in and this API answers on
|
||||
# mcp.nearle.ai.in, so no dist/ is present here and the JSON root
|
||||
# handler at the bottom of this file is what responds to /.
|
||||
#
|
||||
# The candidates cover both repo layouts - the sibling checkout is named
|
||||
# `catalogue_frontend`, and only `frontend` was checked before, so this branch
|
||||
# could never activate even when a build was sitting right next to it.
|
||||
# FRONTEND_DIST_DIR overrides both when the build lands somewhere else.
|
||||
_dist_override = os.getenv("FRONTEND_DIST_DIR", "").strip()
|
||||
_repo_root = Path(__file__).resolve().parents[2]
|
||||
_dist_candidates = (
|
||||
[Path(_dist_override)]
|
||||
if _dist_override
|
||||
else [
|
||||
_repo_root / "catalogue_frontend" / "dist",
|
||||
_repo_root / "frontend" / "dist",
|
||||
Path(__file__).resolve().parents[1] / "frontend" / "dist",
|
||||
]
|
||||
)
|
||||
FRONTEND_DIST = next(
|
||||
(p for p in _dist_candidates if (p / "assets").exists()),
|
||||
_dist_candidates[0],
|
||||
)
|
||||
|
||||
# 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():
|
||||
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}")
|
||||
@@ -130,7 +215,10 @@ if FRONTEND_DIST.exists() and (FRONTEND_DIST / "assets").exists():
|
||||
# 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")):
|
||||
# "mcp" is in this list for the case where the MCP app failed to load:
|
||||
# the mount would be absent, and without this the SPA fallback would
|
||||
# answer an MCP client with index.html and a 200.
|
||||
if full_path.startswith(("api", "docs", "redoc", "openapi.json", "mcp")):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
file_path = FRONTEND_DIST / full_path
|
||||
if file_path.exists() and file_path.is_file():
|
||||
|
||||
Reference in New Issue
Block a user