"""Typed remote and built-in adapters for the recommended app catalog.""" import json import logging import threading from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import cast, override import httpx from cachetools import TTLCache from configs import dify_config from services.recommended_app_query_service import ( RecommendedAppCatalogPage, RecommendedAppCatalogQuery, RecommendedAppDetailRecord, RecommendedAppInfoRecord, RecommendedAppRecord, ) logger = logging.getLogger(__name__) _BUILTIN_FALLBACK_LANGUAGE = "en-US" _BUILTIN_CATALOG_PATH = Path(__file__).resolve().parents[1] / "constants" / "recommended_apps.json" _REMOTE_FETCH_CACHE_MAXSIZE = 64 _remote_fetch_cache: TTLCache[tuple[str, str], object] | None = None _remote_fetch_cache_ttl: int | None = None _remote_fetch_cache_lock = threading.Lock() def _hosted_fetch_cache_ttl() -> int: ttl = dify_config.HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL if isinstance(ttl, int) and not isinstance(ttl, bool): return ttl return 600 def _get_remote_fetch_cache() -> TTLCache[tuple[str, str], object] | None: ttl = _hosted_fetch_cache_ttl() if ttl <= 0: return None global _remote_fetch_cache, _remote_fetch_cache_ttl if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl: with _remote_fetch_cache_lock: if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl: _remote_fetch_cache = TTLCache(maxsize=_REMOTE_FETCH_CACHE_MAXSIZE, ttl=ttl) _remote_fetch_cache_ttl = ttl return _remote_fetch_cache def clear_remote_fetch_cache() -> None: """Reset the in-memory remote fetch cache (used by tests).""" global _remote_fetch_cache, _remote_fetch_cache_ttl with _remote_fetch_cache_lock: _remote_fetch_cache = None _remote_fetch_cache_ttl = None class _RecommendedAppSourceUnavailableError(Exception): pass class BuiltinRecommendedAppCatalogGateway(RecommendedAppCatalogQuery): def __init__(self) -> None: self._data: Mapping[str, object] | None = None @override def list_recommended(self, language: str) -> RecommendedAppCatalogPage: return _map_recommended_page(self._raw_page(language)) @override def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: return _map_learn_dify_page(self._raw_learn_dify_page(language)) @override def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: detail = self._raw_detail(app_id) if detail is None: return None return _map_detail(_as_mapping(detail, field="recommended app detail")) @override def contains(self, app_id: str) -> bool: return self._raw_detail(app_id) is not None def _raw_page(self, language: str) -> Mapping[str, object]: pages = _as_mapping(self._get_data().get("recommended_apps", {}), field="recommended_apps") return _as_mapping(pages.get(language, {}), field="recommended app page") def _raw_learn_dify_page(self, language: str) -> Mapping[str, object]: apps = self._raw_learn_dify_apps(language) if not apps and language != _BUILTIN_FALLBACK_LANGUAGE: apps = self._raw_learn_dify_apps(_BUILTIN_FALLBACK_LANGUAGE) return {"recommended_apps": apps} def _raw_learn_dify_apps(self, language: str) -> tuple[object, ...]: page = self._raw_page(language) return tuple( app for app in _as_sequence(page.get("recommended_apps", ()), field="apps") if _as_mapping(app, field="recommended app").get("is_learn_dify") is True ) def _raw_detail(self, app_id: str) -> object | None: details = _as_mapping(self._get_data().get("app_details", {}), field="app_details") return details.get(app_id) def _get_data(self) -> Mapping[str, object]: if self._data is None: loaded = json.loads(_BUILTIN_CATALOG_PATH.read_text(encoding="utf-8")) self._data = _as_mapping(loaded, field="built-in recommended app catalog") return self._data class RemoteRecommendedAppCatalogGateway(RecommendedAppCatalogQuery): @override def list_recommended(self, language: str) -> RecommendedAppCatalogPage: result = self._fetch(lambda: self._fetch_page(language)) return _map_recommended_page(_as_mapping(result, field="recommended app page")) @override def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: result = self._fetch(lambda: self._fetch_learn_dify_page(language)) return _map_learn_dify_page(_as_mapping(result, field="Learn Dify app page")) @override def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: detail = self._fetch(lambda: self._fetch_detail(app_id)) if detail is None: return None return _map_detail(_as_mapping(detail, field="recommended app detail")) @override def contains(self, app_id: str) -> bool: detail = self._fetch(lambda: self._fetch_detail(app_id)) return detail is not None def _fetch_detail(self, app_id: str) -> object | None: status_code, detail = self._get_payload(f"/apps/{app_id}") if status_code != 200: # Preserve the legacy detail contract: only request or decoding # failures use the bundled fallback; HTTP responses are authoritative. return None return detail def _fetch_page(self, language: str) -> object: status_code, page = self._get_payload(f"/apps?language={language}") if status_code != 200: raise ValueError(f"fetch recommended apps failed, status code: {status_code}") return page def _fetch_learn_dify_page(self, language: str) -> object: status_code, page = self._get_payload(f"/apps/learn-dify?language={language}") if status_code != 200: raise ValueError(f"fetch learn dify apps failed, status code: {status_code}") return page @staticmethod def _get_payload(path: str) -> tuple[int, object]: origin = dify_config.CONSOLE_WEB_URL url = f"{dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN}{path}" headers = {"Origin": origin} if origin else {} cache_key = (url, origin) cache = _get_remote_fetch_cache() if cache is not None: with _remote_fetch_cache_lock: cached = cache.get(cache_key) if cached is not None: return 200, cached response = httpx.get( url, headers=headers, timeout=httpx.Timeout(10.0, connect=3.0), ) if response.status_code != 200: return response.status_code, None result = response.json() if cache is not None: with _remote_fetch_cache_lock: cache[cache_key] = result return response.status_code, result @staticmethod def _fetch[T](fetch: Callable[[], T]) -> T: try: return fetch() except Exception as error: raise _RecommendedAppSourceUnavailableError(str(error)) from error class RecommendedAppCatalogRouter(RecommendedAppCatalogQuery): def __init__( self, *, remote: RecommendedAppCatalogQuery, database: RecommendedAppCatalogQuery, builtin: RecommendedAppCatalogQuery, ) -> None: self._remote = remote self._builtin = builtin self._sources: dict[str, RecommendedAppCatalogQuery] = { "remote": remote, "db": database, "builtin": builtin, } @override def list_recommended(self, language: str) -> RecommendedAppCatalogPage: source = self._source() if source is not self._remote: page = source.list_recommended(language) else: try: page = self._remote.list_recommended(language) except _RecommendedAppSourceUnavailableError as error: logger.warning("fetch recommended apps from dify official failed: %s, switch to built-in.", error) page = self._builtin.list_recommended(language) if not page.recommended_apps: return self._builtin.list_recommended(_BUILTIN_FALLBACK_LANGUAGE) return page @override def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: source = self._source() if source is not self._remote: return source.list_learn_dify(language) try: return self._remote.list_learn_dify(language) except _RecommendedAppSourceUnavailableError as error: logger.warning("fetch learn dify apps from dify official failed: %s, switch to built-in.", error) return self._builtin.list_learn_dify(language) @override def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: source = self._source() if source is not self._remote: return source.get_detail(app_id) try: return self._remote.get_detail(app_id) except _RecommendedAppSourceUnavailableError as error: logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error) return self._builtin.get_detail(app_id) @override def contains(self, app_id: str) -> bool: source = self._source() if source is not self._remote: return source.contains(app_id) try: return self._remote.contains(app_id) except _RecommendedAppSourceUnavailableError as error: logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error) return self._builtin.contains(app_id) def _source(self) -> RecommendedAppCatalogQuery: mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE try: return self._sources[mode] except KeyError: raise ValueError(f"invalid fetch recommended apps mode: {mode}") from None def _map_recommended_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage: if not source.get("recommended_apps"): return RecommendedAppCatalogPage(recommended_apps=(), categories=()) return _map_page(source) def _map_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage: return RecommendedAppCatalogPage( recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")), categories=_as_string_tuple(source["categories"], field="categories"), ) def _map_learn_dify_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage: return RecommendedAppCatalogPage( recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")), categories=(), ) def _map_app(source: object) -> RecommendedAppRecord: source = _as_mapping(source, field="recommended app") app_id = source["app_id"] if not isinstance(app_id, str): raise TypeError("app_id must be a string") app_source = source.get("app") if app_source is not None: app_source = _as_mapping(app_source, field="app") return RecommendedAppRecord( app=_map_app_info(app_source), app_id=app_id, description=cast(str | None, source.get("description")), copyright=cast(str | None, source.get("copyright")), privacy_policy=cast(str | None, source.get("privacy_policy")), custom_disclaimer=cast(str | None, source.get("custom_disclaimer")), categories=_as_string_tuple(source.get("categories", ()), field="categories"), position=cast(int | None, source.get("position")), is_listed=cast(bool | None, source.get("is_listed")), ) def _map_app_info(source: Mapping[str, object] | None) -> RecommendedAppInfoRecord | None: if source is None: return None app_id = source["id"] if not isinstance(app_id, str): raise TypeError("app.id must be a string") return RecommendedAppInfoRecord( id=app_id, name=cast(str | None, source.get("name")), mode=_enum_string(source.get("mode"), field="app.mode"), icon=cast(str | None, source.get("icon")), icon_type=_enum_string(source.get("icon_type"), field="app.icon_type"), icon_background=cast(str | None, source.get("icon_background")), ) def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord: app_id = source["id"] name = source["name"] export_data = source["export_data"] if not isinstance(app_id, str): raise TypeError("id must be a string") if not isinstance(name, str): raise TypeError("name must be a string") if not isinstance(export_data, str): raise TypeError("export_data must be a string") mode = _enum_string(source["mode"], field="mode") if mode is None: raise TypeError("mode must be a string or string enum") return RecommendedAppDetailRecord( id=app_id, name=name, icon=cast(str | None, source.get("icon")), icon_background=cast(str | None, source.get("icon_background")), mode=mode, export_data=export_data, ) def _as_mapping(value: object, *, field: str) -> Mapping[str, object]: if not isinstance(value, Mapping): raise TypeError(f"{field} must be a mapping") return cast(Mapping[str, object], value) def _as_sequence(value: object, *, field: str) -> Sequence[object]: if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): raise TypeError(f"{field} must be a sequence") return cast(Sequence[object], value) def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]: values = _as_sequence(value, field=field) if not all(isinstance(item, str) for item in values): raise TypeError(f"{field} must contain only strings") return cast(tuple[str, ...], tuple(values)) def _enum_string(value: object, *, field: str) -> str | None: if value is None: return None if isinstance(value, str): return str(value) raise TypeError(f"{field} must be a string or string enum")