From a7cb660009034df00914a0b9fc63dbbc38eca4e8 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 24 Feb 2026 22:28:26 +0000 Subject: [PATCH] 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 --- artdag_common/fragments.py | 91 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 artdag_common/fragments.py diff --git a/artdag_common/fragments.py b/artdag_common/fragments.py new file mode 100644 index 0000000..321949b --- /dev/null +++ b/artdag_common/fragments.py @@ -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 + )))