api: SPA fallback sert les shells pré-rendus ; ETag/Cache-Control sur /openapi.json
1 changed file +23 −3
modified
hfmarketdata/api/main.py
+23 −3
@@ -32,7 +32,7 @@ from pathlib import Path | ||
| 32 | 32 | import duckdb |
| 33 | 33 | from fastapi import FastAPI, HTTPException, Query |
| 34 | 34 | from fastapi.middleware.cors import CORSMiddleware |
| 35 | −from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse | |
| 35 | +from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, Response | |
| 36 | 36 | from fastapi.staticfiles import StaticFiles |
| 37 | 37 | |
| 38 | 38 | from core import errors as _errors |
@@ -499,6 +499,22 @@ for _mod in V2_MODULES: | ||
| 499 | 499 | _log.exception("module %s failed to load: %s", _mod, e) |
| 500 | 500 | |
| 501 | 501 | |
| 502 | +# ETag on the OpenAPI document so the docs' If-None-Match revalidation is cheap | |
| 503 | +import hashlib as _hashlib | |
| 504 | + | |
| 505 | +@app.middleware("http") | |
| 506 | +async def _openapi_etag(request, call_next): | |
| 507 | + response = await call_next(request) | |
| 508 | + if request.url.path == "/openapi.json" and response.status_code == 200: | |
| 509 | + body = b"".join([chunk async for chunk in response.body_iterator]) | |
| 510 | + etag = '"' + _hashlib.sha256(body).hexdigest()[:32] + '"' | |
| 511 | + if request.headers.get("if-none-match") == etag: | |
| 512 | + return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "public, max-age=300"}) | |
| 513 | + return Response(body, status_code=200, media_type="application/json", | |
| 514 | + headers={"ETag": etag, "Cache-Control": "public, max-age=300"}) | |
| 515 | + return response | |
| 516 | + | |
| 517 | + | |
| 502 | 518 | # ------------------------------------------------------------------------------ |
| 503 | 519 | # Static React platform (mounted last so it doesn't shadow the API) + SPA fallback |
| 504 | 520 | # ------------------------------------------------------------------------------ |
@@ -510,9 +526,13 @@ if WEB_DIST.is_dir(): | ||
| 510 | 526 | def spa(path: str): |
| 511 | 527 | if path.startswith(("v1/", "health", "openapi", "docs/", "redoc")) and not path.startswith("docs/"): |
| 512 | 528 | raise HTTPException(404, "Not found") |
| 529 | + root = str(WEB_DIST.resolve()) | |
| 513 | 530 | candidate = (WEB_DIST / path).resolve() |
| 514 | − if path and candidate.is_file() and str(candidate).startswith(str(WEB_DIST.resolve())): | |
| 515 | − return FileResponse(candidate) | |
| 531 | + if path and str(candidate).startswith(root): | |
| 532 | + # exact file, prerendered shell (<route>.html or <route>/index.html), else the SPA entry | |
| 533 | + for c in (candidate, candidate.with_suffix(".html") if candidate.suffix == "" else None, candidate / "index.html"): | |
| 534 | + if c is not None and c.is_file(): | |
| 535 | + return FileResponse(c) | |
| 516 | 536 | return FileResponse(WEB_DIST / "index.html") |
| 517 | 537 | |
| 518 | 538 | |
| 519 | 539 | |