diff --git a/infrastructure/fragments.py b/infrastructure/fragments.py new file mode 100644 index 0000000..90754a0 --- /dev/null +++ b/infrastructure/fragments.py @@ -0,0 +1,160 @@ +""" +Server-side fragment composition client. + +Each coop app exposes HTML fragments at ``/internal/fragments/{type}``. +This module provides helpers to fetch and cache those fragments so that +consuming apps can compose cross-app UI without shared templates. + +All functions return ``""`` on error (graceful degradation — a missing +fragment simply means a section is absent from the page). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Sequence + +import httpx + +log = logging.getLogger(__name__) + +# Re-usable async client (created lazily, one per process) +_client: httpx.AsyncClient | None = None + +# Default request timeout (seconds) +_DEFAULT_TIMEOUT = 2.0 + +# Header sent on every fragment request so providers can distinguish +# fragment fetches from normal browser traffic. +FRAGMENT_HEADER = "X-Fragment-Request" + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None or _client.is_closed: + _client = httpx.AsyncClient( + timeout=httpx.Timeout(_DEFAULT_TIMEOUT), + follow_redirects=False, + ) + return _client + + +def _internal_url(app_name: str) -> str: + """Resolve the Docker-internal base URL for *app_name*. + + Looks up ``INTERNAL_URL_{APP}`` first, falls back to + ``http://{app}:8000``. + """ + env_key = f"INTERNAL_URL_{app_name.upper()}" + return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/") + + +# ------------------------------------------------------------------ +# Public API +# ------------------------------------------------------------------ + +async def fetch_fragment( + app_name: str, + fragment_type: str, + *, + params: dict | None = None, + timeout: float = _DEFAULT_TIMEOUT, +) -> str: + """Fetch an HTML fragment from another app. + + Returns the raw HTML string, or ``""`` on any error. + """ + base = _internal_url(app_name) + url = f"{base}/internal/fragments/{fragment_type}" + try: + resp = await _get_client().get( + url, + params=params, + headers={FRAGMENT_HEADER: "1"}, + timeout=timeout, + ) + if resp.status_code == 200: + return resp.text + log.debug("Fragment %s/%s returned %s", app_name, fragment_type, resp.status_code) + return "" + except Exception: + log.debug("Fragment %s/%s failed", app_name, fragment_type, exc_info=True) + return "" + + +async def fetch_fragments( + requests: Sequence[tuple[str, str, dict | None]], + *, + timeout: float = _DEFAULT_TIMEOUT, +) -> list[str]: + """Fetch multiple fragments concurrently. + + *requests* is a sequence of ``(app_name, fragment_type, params)`` tuples. + Returns a list of HTML strings in the same order. Failed fetches + produce ``""``. + """ + return list(await asyncio.gather(*( + fetch_fragment(app, ftype, params=params, timeout=timeout) + for app, ftype, params in requests + ))) + + +async def fetch_fragment_cached( + app_name: str, + fragment_type: str, + *, + params: dict | None = None, + ttl: int = 30, + timeout: float = _DEFAULT_TIMEOUT, +) -> str: + """Fetch a fragment with a Redis cache layer. + + Cache key: ``frag:{app}:{type}:{sorted_params}``. + Returns ``""`` on error (cache miss + fetch failure). + """ + # Build a stable cache key + suffix = "" + if params: + sorted_items = sorted(params.items()) + suffix = ":" + "&".join(f"{k}={v}" for k, v in sorted_items) + cache_key = f"frag:{app_name}:{fragment_type}{suffix}" + + # Try Redis cache + redis = _get_redis() + if redis: + try: + cached = await redis.get(cache_key) + if cached is not None: + return cached.decode() if isinstance(cached, bytes) else cached + except Exception: + pass + + # Cache miss — fetch from provider + html = await fetch_fragment( + app_name, fragment_type, params=params, timeout=timeout, + ) + + # Store in cache (even empty string — avoids hammering a down service) + if redis and ttl > 0: + try: + await redis.set(cache_key, html.encode(), ex=ttl) + except Exception: + pass + + return html + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _get_redis(): + """Return the current app's Redis connection, or None.""" + try: + from quart import current_app + r = current_app.redis + return r if r else None + except Exception: + return None diff --git a/infrastructure/jinja_setup.py b/infrastructure/jinja_setup.py index 3902394..01c80e7 100644 --- a/infrastructure/jinja_setup.py +++ b/infrastructure/jinja_setup.py @@ -107,5 +107,14 @@ def setup_jinja(app: Quart) -> None: from shared.services.widget_registry import widgets as _widget_registry app.jinja_env.globals["widgets"] = _widget_registry + # fragment composition helper — fetch HTML from another app's fragment API + from shared.infrastructure.fragments import fetch_fragment_cached + + async def _fragment(app_name: str, fragment_type: str, ttl: int = 30, **params) -> str: + p = params if params else None + return await fetch_fragment_cached(app_name, fragment_type, params=p, ttl=ttl) + + app.jinja_env.globals["fragment"] = _fragment + # register jinja filters register_filters(app)