NSchuetz commited on
Commit
ea8b332
·
verified ·
1 Parent(s): b864603

Add /api/data JSON endpoint (3 tracks incl. downstream) + CORS

Browse files
Files changed (1) hide show
  1. app.py +73 -3
app.py CHANGED
@@ -2,8 +2,8 @@
2
 
3
  Computes the leaderboard live from the per-user substrate in the
4
  ``MyHeartCounts/OpenMHC-leaderboard-data`` HF dataset (see
5
- ``leaderboard_compute.py``) and serves it as an HTML page. There is no JSON
6
- API this Space *is* the leaderboard UI.
7
 
8
  The dataset is public, so no token is required. Styling mirrors the public
9
  MyHeartCounts / OpenMHC site (light theme, red accent). The table is grouped by
@@ -13,10 +13,12 @@ sub-track (single-day / long-context) and can be filtered by method type.
13
  from __future__ import annotations
14
 
15
  import html
 
16
  from pathlib import Path
17
 
18
  from fastapi import FastAPI
19
- from fastapi.responses import FileResponse, HTMLResponse
 
20
 
21
  from leaderboard_compute import (
22
  compute_downstream_rows,
@@ -26,6 +28,14 @@ from leaderboard_compute import (
26
 
27
  app = FastAPI(title="OpenMHC Leaderboard", docs_url=None, redoc_url=None)
28
 
 
 
 
 
 
 
 
 
29
  # Compute is mildly expensive (download + reduce); cache per track in-process.
30
  _CACHE: dict = {
31
  "downstream": {"rows": None, "error": None},
@@ -681,6 +691,66 @@ def health() -> dict:
681
  return {"status": "ok" if ok else "error", "tracks": tracks}
682
 
683
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
684
  @app.get("/logo.png")
685
  def logo() -> FileResponse:
686
  return FileResponse(Path(__file__).parent / "logo.png", media_type="image/png")
 
2
 
3
  Computes the leaderboard live from the per-user substrate in the
4
  ``MyHeartCounts/OpenMHC-leaderboard-data`` HF dataset (see
5
+ ``leaderboard_compute.py``) and serves it as an HTML page. The same data is
6
+ also exposed as JSON at ``/api/data`` (CORS-enabled) for the public site.
7
 
8
  The dataset is public, so no token is required. Styling mirrors the public
9
  MyHeartCounts / OpenMHC site (light theme, red accent). The table is grouped by
 
13
  from __future__ import annotations
14
 
15
  import html
16
+ import math
17
  from pathlib import Path
18
 
19
  from fastapi import FastAPI
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
22
 
23
  from leaderboard_compute import (
24
  compute_downstream_rows,
 
28
 
29
  app = FastAPI(title="OpenMHC Leaderboard", docs_url=None, redoc_url=None)
30
 
31
+ # The public site (myheartcounts.stanford.edu) reads /api/data cross-origin.
32
+ app.add_middleware(
33
+ CORSMiddleware,
34
+ allow_origins=["*"],
35
+ allow_methods=["GET"],
36
+ allow_headers=["*"],
37
+ )
38
+
39
  # Compute is mildly expensive (download + reduce); cache per track in-process.
40
  _CACHE: dict = {
41
  "downstream": {"rows": None, "error": None},
 
691
  return {"status": "ok" if ok else "error", "tracks": tracks}
692
 
693
 
694
+ # ---------------------------------------------------------------------------
695
+ # JSON API — consumed cross-origin by the public MyHeartCounts site.
696
+ # ---------------------------------------------------------------------------
697
+
698
+
699
+ def _sanitize(row: dict) -> dict:
700
+ """Replace non-finite floats (NaN/inf) with None so the row is valid JSON.
701
+
702
+ Starlette's JSONResponse serialises with ``allow_nan=False``; an unsanitised
703
+ NaN would 500 the endpoint and break the client's ``.json()``.
704
+ """
705
+ return {
706
+ k: (None if isinstance(v, float) and not math.isfinite(v) else v)
707
+ for k, v in row.items()
708
+ }
709
+
710
+
711
+ def _api_subtracks(cfg: dict, rows: list[dict]) -> list[tuple[str, str]]:
712
+ """Subtracks for the payload; mirrors the HTML's dynamic "Other" bucket.
713
+
714
+ The frontend silently drops rows whose ``subtrack`` matches no subtrack key,
715
+ so when any row falls outside the configured set we append ("other", "Other")
716
+ — exactly as ``_table`` does for the HTML page.
717
+ """
718
+ subs = list(cfg["subtracks"])
719
+ if subs:
720
+ known = {k for k, _ in subs}
721
+ if any(r.get("subtrack") not in known for r in rows):
722
+ subs = subs + [("other", "Other")]
723
+ return subs
724
+
725
+
726
+ def _track_payload(cfg: dict, rows: list[dict] | None, error: str | None) -> dict:
727
+ # `fallback` is a downstream-only column in the public API contract.
728
+ columns = [
729
+ {"key": k, "label": label}
730
+ for k, label in cfg["columns"]
731
+ if not (k == "fallback" and cfg["key"] != "downstream")
732
+ ]
733
+ rows = rows or []
734
+ return {
735
+ "title": cfg["title"],
736
+ "tab": cfg["tab"],
737
+ "columns": columns,
738
+ "subtracks": [{"key": k, "label": label} for k, label in _api_subtracks(cfg, rows)],
739
+ "legend_html": cfg["note"],
740
+ "rows": [_sanitize(r) for r in rows],
741
+ "error": error,
742
+ }
743
+
744
+
745
+ @app.get("/api/data")
746
+ def api_data() -> JSONResponse:
747
+ payload = {
748
+ cfg["key"]: _track_payload(cfg, *_rows(cfg["key"], cfg["compute"]))
749
+ for cfg in TRACKS
750
+ }
751
+ return JSONResponse(payload)
752
+
753
+
754
  @app.get("/logo.png")
755
  def logo() -> FileResponse:
756
  return FileResponse(Path(__file__).parent / "logo.png", media_type="image/png")