Add fragment composition infrastructure for micro-frontend UI
New HTTP client (fragments.py) fetches HTML fragments from other apps over the Docker network, with Redis caching and graceful degradation. Jinja global `fragment()` available in all templates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
160
infrastructure/fragments.py
Normal file
160
infrastructure/fragments.py
Normal file
@@ -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
|
||||||
@@ -107,5 +107,14 @@ def setup_jinja(app: Quart) -> None:
|
|||||||
from shared.services.widget_registry import widgets as _widget_registry
|
from shared.services.widget_registry import widgets as _widget_registry
|
||||||
app.jinja_env.globals["widgets"] = _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 jinja filters
|
||||||
register_filters(app)
|
register_filters(app)
|
||||||
|
|||||||
Reference in New Issue
Block a user