-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmain.py
More file actions
234 lines (192 loc) · 8.33 KB
/
Copy pathmain.py
File metadata and controls
234 lines (192 loc) · 8.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
"""
FastAPI backend for anyplot platform.
AI-powered plotting examples that work with YOUR data.
"""
# Load .env file FIRST, before any other imports that might read env vars
from dotenv import load_dotenv # noqa: E402, I001
load_dotenv()
import logging # noqa: E402
from contextlib import asynccontextmanager # noqa: E402
from fastapi import FastAPI, HTTPException, Request, Response # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from starlette.middleware.gzip import GZipMiddleware # noqa: E402
from api.cache import cache_key, set_cache # noqa: E402
from api.exceptions import ( # noqa: E402
AnyplotException,
anyplot_exception_handler,
generic_exception_handler,
http_exception_handler,
)
from api.mcp.server import mcp_server # noqa: E402
from api.routers import ( # noqa: E402
debug_router,
download_router,
feedback_router,
health_router,
insights_router,
languages_router,
libraries_router,
og_images_router,
plots_router,
proxy_router,
seo_router,
specs_router,
stats_router,
)
from api.routers.languages import _refresh_languages # noqa: E402
from api.routers.libraries import _refresh_libraries # noqa: E402
from api.routers.plots import _refresh_filter_all # noqa: E402
from api.routers.specs import _refresh_specs_list, _refresh_specs_map # noqa: E402
from api.routers.stats import _refresh_stats # noqa: E402
from core.config import settings # noqa: E402
from core.database import close_db, init_db, is_db_configured # noqa: E402
# Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
# Create MCP HTTP app (needed for lifespan integration)
mcp_http_app = mcp_server.http_app(path="/")
async def _prewarm_cache() -> None:
"""Populate the in-memory cache for the endpoints the frontend hits on
page load: the four AppDataProvider metadata calls (/stats, /libraries,
/languages, /specs) plus the two heaviest user-facing payloads — the
unfiltered gallery (/plots/filter → `filter:all`) and the /map page
(/specs/map) — which would otherwise take the full cold-cache DB
roundtrip for the first visitor of every new instance.
The cache lives per Cloud Run instance, so every new instance that comes
up from autoscale or a cold start would otherwise force its first user
to wait on the full DB roundtrip — which is exactly the user-reported
"manchmal echt lange" on the NumbersStrip and the /specs page. Prewarming
runs once per process startup so the first request hits a warm cache.
Failures here are non-fatal: log and continue. A failed prewarm just
means the first user request takes the cold-cache path it would have
taken without this hook.
"""
# Ordered lightest-first so the cheap metadata endpoints are warm even
# while the two heavy payloads are still being computed.
refreshers = (
("stats", _refresh_stats),
("libraries", _refresh_libraries),
("languages", _refresh_languages),
("specs_list", _refresh_specs_list),
("specs_map", _refresh_specs_map),
("filter:all", _refresh_filter_all),
)
for key, factory in refreshers:
try:
result = await factory()
set_cache(cache_key(key), result)
logger.info("Cache prewarm: %s OK", key)
except Exception:
logger.warning("Cache prewarm failed for %s — falling back to lazy load", key, exc_info=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifecycle."""
logger.info("Starting anyplot API...")
# Initialize database connection
if is_db_configured():
try:
await init_db()
logger.info("Database connection initialized")
await _prewarm_cache()
except Exception as e:
logger.error(f"Failed to initialize database: {e}")
# Initialize MCP server lifespan
async with mcp_http_app.lifespan(app):
logger.info("MCP server initialized")
yield
# Cleanup database connection
logger.info("Shutting down anyplot API...")
await close_db()
# Create FastAPI application
app = FastAPI(
title="anyplot API",
description="Backend API for anyplot.ai - plotting gallery across 9 libraries",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
)
# Register exception handlers
app.add_exception_handler(AnyplotException, anyplot_exception_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(Exception, generic_exception_handler)
# Enable GZip compression for responses > 500 bytes
# This significantly reduces payload size for JSON API responses
# (e.g., /plots/filter: 301KB -> ~40KB with gzip)
# Note: GZip must be added before CORS so compression happens before CORS headers are added
app.add_middleware(GZipMiddleware, minimum_size=500)
# Configure CORS. Origins come from settings.cors_origins (single source of
# truth — a hardcoded list here previously left https://www.anyplot.ai out
# even though config promised it); the regex additionally allows any
# localhost port for local dev servers.
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_origin_regex=r"http://localhost:\d+",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["Mcp-Session-Id"], # MCP session tracking
)
# Add cache headers middleware
@app.middleware("http")
async def add_cache_headers(request: Request, call_next):
"""Add Cache-Control headers to API responses for better browser caching."""
response: Response = await call_next(request)
# Skip for non-GET requests or error responses
if request.method != "GET" or response.status_code >= 400:
return response
path = request.url.path
# Static data — changes only on deploy (10 min cache, 1h stale-while-revalidate)
if path in ("/libraries", "/languages", "/stats"):
response.headers["Cache-Control"] = "public, max-age=600, stale-while-revalidate=3600"
# Specs list (5 min cache, 1h stale-while-revalidate)
elif path == "/specs":
response.headers["Cache-Control"] = "public, max-age=300, stale-while-revalidate=3600"
# Filter endpoint — most dynamic, moderate cache (1 min, 10 min stale)
elif path == "/plots/filter":
response.headers["Cache-Control"] = "public, max-age=60, stale-while-revalidate=600"
# Individual spec details (5 min cache, 1h stale-while-revalidate)
elif path.startswith("/specs/"):
response.headers["Cache-Control"] = "public, max-age=300, stale-while-revalidate=3600"
# Insights endpoints (5 min cache, 1h stale-while-revalidate)
elif path.startswith("/insights/"):
response.headers["Cache-Control"] = "public, max-age=300, stale-while-revalidate=3600"
return response
# Mount MCP server for AI assistant integration
app.mount("/mcp", mcp_http_app)
# Register routers
app.include_router(health_router)
app.include_router(stats_router)
app.include_router(specs_router)
app.include_router(libraries_router)
app.include_router(languages_router)
app.include_router(plots_router)
app.include_router(insights_router)
app.include_router(download_router)
app.include_router(seo_router)
app.include_router(og_images_router)
app.include_router(proxy_router)
app.include_router(debug_router)
app.include_router(feedback_router)
# ASGI middleware to handle /mcp without trailing slash
# This runs BEFORE FastAPI routing, avoiding the 307 redirect
# MCP clients like Claude CLI don't follow redirects
class MCPTrailingSlashMiddleware:
"""Rewrite /mcp to /mcp/ before routing to avoid 307 redirect."""
def __init__(self, asgi_app):
self.asgi_app = asgi_app
async def __call__(self, scope, receive, send):
if scope["type"] == "http" and scope["path"] == "/mcp":
scope = scope.copy()
scope["path"] = "/mcp/"
await self.asgi_app(scope, receive, send)
# Wrap the FastAPI app with the middleware
# This must be done after all routers are registered
# Keep reference to FastAPI instance for tests
fastapi_app = app
app = MCPTrailingSlashMiddleware(app)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)