""" 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