Add fragment client for coop app composition
Lightweight httpx-based client to fetch HTML fragments from coop apps (nav-tree, auth-menu, cart-mini) using internal Docker URLs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
91
artdag_common/fragments.py
Normal file
91
artdag_common/fragments.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Fragment client for fetching HTML fragments from coop apps.
|
||||||
|
|
||||||
|
Lightweight httpx-based client (no Quart dependency) for Art-DAG to consume
|
||||||
|
coop app fragments like nav-tree, auth-menu, and cart-mini.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FRAGMENT_HEADER = "X-Fragment-Request"
|
||||||
|
|
||||||
|
_client: httpx.AsyncClient | None = None
|
||||||
|
_DEFAULT_TIMEOUT = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
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 internal base URL for a coop app.
|
||||||
|
|
||||||
|
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("/")
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_fragment(
|
||||||
|
app_name: str,
|
||||||
|
fragment_type: str,
|
||||||
|
*,
|
||||||
|
params: dict | None = None,
|
||||||
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
|
required: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Fetch an HTML fragment from a coop app.
|
||||||
|
|
||||||
|
Returns empty string on failure by default (required=False).
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
msg = f"Fragment {app_name}/{fragment_type} returned {resp.status_code}"
|
||||||
|
log.warning(msg)
|
||||||
|
if required:
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
return ""
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
msg = f"Fragment {app_name}/{fragment_type} failed: {exc}"
|
||||||
|
log.warning(msg)
|
||||||
|
if required:
|
||||||
|
raise RuntimeError(msg) from exc
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_fragments(
|
||||||
|
requests: Sequence[tuple[str, str, dict | None]],
|
||||||
|
*,
|
||||||
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
|
required: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Fetch multiple fragments concurrently."""
|
||||||
|
return list(await asyncio.gather(*(
|
||||||
|
fetch_fragment(app, ftype, params=params, timeout=timeout, required=required)
|
||||||
|
for app, ftype, params in requests
|
||||||
|
)))
|
||||||
Reference in New Issue
Block a user