diff --git a/goodnews/api.py b/goodnews/api.py index 2b82025..954edab 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -45,6 +45,16 @@ from .lanes import build_lane_pool from .paywall import is_paywalled from .taxonomy import FAMILIES, FLAVORS, TOPICS +# Edge-cache directives for GLOBAL endpoints — responses that depend only on the +# URL, never the session/user, so Cloudflare may safely serve one visitor's copy +# to another (this is what makes "Gathering the good news…" resolve from the edge +# instead of a round-trip to the residential origin). The personalization +# boundary is hard: anything session- or filter-specific stays private/no-store. +_EDGE_CONFIG = "public, max-age=0, s-maxage=900, stale-while-revalidate=600" # static config (moods/categories) +_EDGE_DERIVED = "public, max-age=0, s-maxage=120, stale-while-revalidate=120" # global, data-derived (lanes/families) +_EDGE_FEED = "public, max-age=0, s-maxage=45, stale-while-revalidate=30" # global feed (URL-keyed, shareable only) +_PRIVATE = "private, no-store" # never share across users + ROOT = Path(__file__).resolve().parents[1] DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3" # Prefer the built SvelteKit site; fall back to the legacy single-page harness. @@ -1410,23 +1420,26 @@ def create_app() -> FastAPI: return {"ok": True} @app.get("/api/categories", response_model=CategoriesResponse) - def categories() -> CategoriesResponse: + def categories(response: Response) -> CategoriesResponse: + response.headers["Cache-Control"] = _EDGE_CONFIG # static taxonomy, identical for everyone return CategoriesResponse( topics=[Category(key=k, description=v) for k, v in TOPICS.items()], flavors=[Category(key=k, description=v) for k, v in FLAVORS.items()], ) @app.get("/api/moods") - def moods() -> list[dict]: + def moods(response: Response) -> list[dict]: # The humane front door: each mood resolves to a filter preset the # client merges with the user's own Calm Filters. + response.headers["Cache-Control"] = _EDGE_CONFIG # static presets, identical for everyone return MOODS @app.get("/api/lanes") - def lanes() -> dict: + def lanes(response: Response) -> dict: # The customizable quick-access rail: 'today' is always pinned, and the # reader pins any subset of these moods / topics / Discovery tags. Live # counts let the client gate empty lanes and show volume. + response.headers["Cache-Control"] = _EDGE_DERIVED # global counts, no per-user data with get_conn() as conn: tagc = queries.tag_counts(conn) topicc: dict[str, int] = {} @@ -1435,8 +1448,9 @@ def create_app() -> FastAPI: return build_lane_pool(topicc, tagc) @app.get("/api/families") - def families() -> list[dict]: + def families(response: Response) -> list[dict]: # Grouping vocabulary organised into calm families for the Explore UI. + response.headers["Cache-Control"] = _EDGE_DERIVED # global vocab + counts, no per-user data with get_conn() as conn: counts = queries.tag_counts(conn) return [ @@ -1468,6 +1482,7 @@ def create_app() -> FastAPI: @app.get("/api/feed", response_model=FeedResponse) def feed( + response: Response, topic: str | None = Query(None), flavor: str | None = Query(None), accepted_only: bool = True, @@ -1481,6 +1496,13 @@ def create_app() -> FastAPI: following: bool = Query(False, description="restrict to the signed-in user's followed sources/tags"), request: Request = None, ) -> FeedResponse: + # Edge-cacheable ONLY when the response depends purely on the URL: not the + # following feed (reads the session's follows) and not personal filters + # (prefs/dismissals are per-reader). The shareable cases — the default + # home feed, topic/flavor/tag/source browse — are identical for everyone, + # so the edge can serve one copy to all. Everything else stays private. + shareable = not following and not prefs and not exclude.strip() + response.headers["Cache-Control"] = _EDGE_FEED if shareable else _PRIVATE if topic and topic.lower() not in TOPICS: raise HTTPException(400, f"unknown topic: {topic}") if flavor and flavor.lower() not in FLAVORS: diff --git a/tests/test_api.py b/tests/test_api.py index 47ae984..f977e3b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -87,3 +87,23 @@ def test_families_endpoint(client): names = [f["name"] for f in fams] assert "Discovery & Wonder" in names assert all("tags" in f and isinstance(f["tags"], list) for f in fams) + + +def test_global_endpoints_are_edge_cacheable(client): + # The startup endpoints are identical for every visitor → publicly cacheable + # so "Gathering the good news…" resolves from the edge, not the origin. + for path in ("/api/moods", "/api/categories", "/api/lanes", "/api/families"): + cc = client.get(path).headers.get("cache-control", "") + assert "public" in cc and "s-maxage" in cc, f"{path}: {cc!r}" + + +def test_feed_cache_boundary(client): + # Shareable (URL-determined) feeds are public; personalized ones are private. + public_cc = client.get("/api/feed").headers.get("cache-control", "") + assert "public" in public_cc and "s-maxage" in public_cc + # topic/tag browse is still shareable (same for everyone) + assert "public" in client.get("/api/feed", params={"topic": "science"}).headers.get("cache-control", "") + # personal filters + the following feed must never be shared across users + assert client.get("/api/feed", params={"following": "true"}).headers.get("cache-control") == "private, no-store" + assert client.get("/api/feed", params={"prefs": json.dumps({"mute_topics": ["science"]})}).headers.get("cache-control") == "private, no-store" + assert client.get("/api/feed", params={"exclude": "1,2"}).headers.get("cache-control") == "private, no-store"