新增六边形架构核心代码包,包含: 1. 协议适配器层:HTTP/SMTP/IMAP/SSH/GRPC等多协议适配器实现 2. 认证插件体系:基础认证、API密钥、HMAC等多类型认证插件 3. 执行编排框架:工具执行器、上下文构建、运行时治理组件 4. 用例端口与DTO:定义领域服务端口与数据传输对象 5. 厂商集成包框架:支持第三方系统集成扩展 6. 基础设施装配层:实现依赖注入与服务装配 所有代码遵循六边形架构设计原则,实现端口与适配器解耦,支持动态扩展与自动发现。
276 lines
10 KiB
Python
276 lines
10 KiB
Python
"""Zendesk discover / preview_tools / create_tools handler 实现。
|
||
|
||
handler 签名遵循 ``OperationHandler`` 协议:
|
||
``(source_type: str, system_config: dict[str, Any]) -> Awaitable[Any]``。
|
||
|
||
handler 内部职责(见设计文档 §7.4):
|
||
1. 从 ``system_config`` 提取 ``base_url`` / ``auth_config`` / ``_resolved_token`` 等
|
||
2. 构建 ``auth_headers``(token 由 use_cases 层注入到 ``system_config["_resolved_token"]``)
|
||
3. 构造带 429 退避的 ``request_fn``,注入到生成器 payload
|
||
4. 调用 ``discover_zendesk_metadata`` 或 ``ZendeskConfigGenerator``
|
||
|
||
429 退避分层(见设计文档 §5.6):
|
||
- discover/preview/create 阶段:本模块 ``_request_with_retry`` 实现(解析 Retry-After)
|
||
- 运行时工具执行:通过工具 ``retry_policy.retry_status_codes: [429, 502, 503, 504]``
|
||
配置(见 generators.py)
|
||
|
||
认证头构建(见设计文档 §3.5):
|
||
- OAuth2: ``system_config["_resolved_token"]["access_token"]`` → Bearer
|
||
- basic (API Token): ``system_config["auth_config"]["username"]`` / ``["password"]`` → Basic
|
||
- token 由 use_cases 层通过 token_manager.get_token 预处理后注入到
|
||
``system_config["_resolved_token"]``,handler 不自行换取 token
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import base64
|
||
from collections.abc import Awaitable, Callable
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from yuxi.external_systems.exceptions import (
|
||
AuthError,
|
||
DomainValidationError,
|
||
ExecutionError,
|
||
RateLimitExceededError,
|
||
)
|
||
from yuxi.external_systems.integrations.schemas import GeneratedToolsDraft
|
||
from yuxi.external_systems.integrations.zendesk.error_extractor import (
|
||
map_zendesk_error,
|
||
)
|
||
from yuxi.external_systems.integrations.zendesk.generators import (
|
||
ZendeskConfigGenerator,
|
||
)
|
||
from yuxi.external_systems.integrations.zendesk.metadata import (
|
||
resolve_oauth_endpoints,
|
||
)
|
||
from yuxi.external_systems.integrations.zendesk.zendesk_metadata import (
|
||
discover_zendesk_metadata,
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
import httpx
|
||
|
||
# discover/preview/create 阶段 429 退避参数
|
||
_RETRY_MAX_ATTEMPTS = 5
|
||
_RETRY_BASE_DELAY = 1.0
|
||
|
||
# Zendesk 通用请求头(除 Authorization 外)
|
||
_ZENDESK_HEADERS: dict[str, str] = {
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
|
||
def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]:
|
||
"""从 system_config 提取凭证,构建 Zendesk 请求头。
|
||
|
||
两种认证形态:
|
||
- OAuth2: ``system_config["_resolved_token"]["access_token"]`` → Bearer
|
||
- basic (API Token): ``system_config["auth_config"]["username"]`` / ``["password"]`` → Basic
|
||
|
||
token 由 use_cases 层通过 ``token_manager.get_token`` 预处理后注入到
|
||
``system_config["_resolved_token"]``。handler 不自行换取 token。
|
||
"""
|
||
headers = dict(_ZENDESK_HEADERS)
|
||
token_info = system_config.get("_resolved_token")
|
||
if isinstance(token_info, dict) and token_info.get("access_token"):
|
||
headers["Authorization"] = f"Bearer {token_info['access_token']}"
|
||
return headers
|
||
# 非 token 类认证(basic):由 use_cases 层解析后注入 auth_config
|
||
auth_config = system_config.get("auth_config") or {}
|
||
username = auth_config.get("username")
|
||
password = auth_config.get("password")
|
||
if username and password:
|
||
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||
headers["Authorization"] = f"Basic {encoded}"
|
||
return headers
|
||
raise AuthError("zendesk: system_config 缺少 _resolved_token 或 auth_config 凭证,use_cases 层未注入凭证")
|
||
|
||
|
||
def _resolve_base_url(system_config: dict[str, Any]) -> str:
|
||
"""从 system_config 提取 base_url,支持 subdomain 拼接。"""
|
||
connection_config = system_config.get("connection_config") or {}
|
||
base_url = connection_config.get("base_url")
|
||
if not base_url:
|
||
subdomain = connection_config.get("subdomain")
|
||
if not subdomain:
|
||
raise DomainValidationError("zendesk: system_config.connection_config 需包含 base_url 或 subdomain")
|
||
base_url = f"https://{subdomain}.zendesk.com"
|
||
return str(base_url).rstrip("/")
|
||
|
||
|
||
def _resolve_resources(system_config: dict[str, Any]) -> list[str] | None:
|
||
"""从 system_config 提取资源过滤白名单。"""
|
||
discovery_options = system_config.get("discovery_options") or {}
|
||
return discovery_options.get("resources")
|
||
|
||
|
||
async def _request_with_retry(
|
||
method: str,
|
||
url: str,
|
||
headers: dict[str, str],
|
||
params: dict[str, Any] | None = None,
|
||
*,
|
||
max_retries: int = _RETRY_MAX_ATTEMPTS,
|
||
base_delay: float = _RETRY_BASE_DELAY,
|
||
) -> httpx.Response:
|
||
"""handler 内部 HTTP 调用的 429 退避(仅用于 discover/preview/create 阶段)。
|
||
|
||
优先使用响应头 ``Retry-After``,否则指数退避(``base_delay * 2 ** attempt``)。
|
||
运行时工具执行的 429 退避由框架 executor + 工具 retry_policy 处理,
|
||
不在此函数范围内。
|
||
|
||
非 2xx 响应(非 429)通过 ``map_zendesk_error`` 转换为 ExternalSystemError 子类。
|
||
网络层异常(超时 / 连接错误)转换为 ``ExecutionError``。
|
||
"""
|
||
import httpx
|
||
|
||
try:
|
||
async with httpx.AsyncClient() as client:
|
||
last_response: httpx.Response | None = None
|
||
for attempt in range(max_retries + 1):
|
||
response = await client.request(method, url, headers=headers, params=params)
|
||
if response.status_code != 429:
|
||
last_response = response
|
||
break
|
||
if attempt == max_retries:
|
||
retry_after_header = response.headers.get("Retry-After", "60")
|
||
try:
|
||
retry_after = int(retry_after_header)
|
||
except ValueError:
|
||
retry_after = 60
|
||
raise RateLimitExceededError(
|
||
f"Zendesk 限流,已重试 {max_retries} 次仍失败: {url}",
|
||
retry_after=retry_after,
|
||
limit_type="qps",
|
||
)
|
||
# 优先使用 Retry-After 头,否则指数退避
|
||
retry_after_header = response.headers.get("Retry-After")
|
||
if retry_after_header:
|
||
try:
|
||
delay = float(retry_after_header)
|
||
except ValueError:
|
||
delay = base_delay * (2**attempt)
|
||
else:
|
||
delay = base_delay * (2**attempt)
|
||
await asyncio.sleep(delay)
|
||
except httpx.RequestError as exc:
|
||
raise ExecutionError(f"Zendesk 网络请求失败: {exc}") from exc
|
||
|
||
assert last_response is not None
|
||
if last_response.is_success:
|
||
return last_response
|
||
# 非 2xx:解析错误 body 并映射为 ExternalSystemError 子类
|
||
try:
|
||
error_body = last_response.json()
|
||
except Exception:
|
||
error_body = {"error": {"message": last_response.text}}
|
||
raise map_zendesk_error(last_response.status_code, error_body)
|
||
|
||
|
||
def _make_request_fn() -> Callable[
|
||
[str, str, dict[str, str], dict[str, Any] | None],
|
||
Awaitable[httpx.Response],
|
||
]:
|
||
"""构造带 429 退避的 request_fn,供 discover_zendesk_metadata 使用。"""
|
||
|
||
async def request_fn(
|
||
method: str,
|
||
url: str,
|
||
headers: dict[str, str],
|
||
params: dict[str, Any] | None = None,
|
||
) -> httpx.Response:
|
||
return await _request_with_retry(method, url, headers, params)
|
||
|
||
return request_fn
|
||
|
||
|
||
async def discover_zendesk(
|
||
source_type: str,
|
||
system_config: dict[str, Any],
|
||
) -> list[dict[str, Any]]:
|
||
"""discover handler:发现 Zendesk 实例可用资源摘要。
|
||
|
||
Returns:
|
||
资源摘要列表,每项含 ``name`` / ``display_name`` / ``field_count``。
|
||
"""
|
||
base_url = _resolve_base_url(system_config)
|
||
auth_headers = _build_auth_headers(system_config)
|
||
resources = _resolve_resources(system_config)
|
||
request_fn = _make_request_fn()
|
||
|
||
metadata = await discover_zendesk_metadata(
|
||
base_url,
|
||
auth_headers,
|
||
resources=resources,
|
||
request_fn=request_fn,
|
||
)
|
||
return [
|
||
{
|
||
"name": r.name,
|
||
"display_name": r.display_name,
|
||
"field_count": len(r.fields),
|
||
}
|
||
for r in metadata
|
||
]
|
||
|
||
|
||
async def preview_zendesk_tools(
|
||
source_type: str,
|
||
system_config: dict[str, Any],
|
||
) -> list[dict[str, Any]]:
|
||
"""preview_tools handler:预览将生成的工具列表。
|
||
|
||
Returns:
|
||
工具预览列表,每项为 ExternalToolCreateInput 兼容的 dict。
|
||
"""
|
||
payload = _build_generator_payload(system_config)
|
||
generator = ZendeskConfigGenerator()
|
||
return await generator.generate(payload)
|
||
|
||
|
||
async def create_zendesk_tools(
|
||
source_type: str,
|
||
system_config: dict[str, Any],
|
||
) -> GeneratedToolsDraft:
|
||
"""create_tools handler:生成工具草稿(不直接持久化)。
|
||
|
||
Returns:
|
||
GeneratedToolsDraft,由 use_cases 层统一持久化。
|
||
"""
|
||
payload = _build_generator_payload(system_config)
|
||
generator = ZendeskConfigGenerator()
|
||
tool_configs = await generator.generate(payload)
|
||
return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False)
|
||
|
||
|
||
def _build_generator_payload(system_config: dict[str, Any]) -> dict[str, Any]:
|
||
"""从 system_config 构造生成器 payload。
|
||
|
||
生成器 payload 是 system_config 的子集,包含:
|
||
- base_url: str
|
||
- auth_headers: dict[str, str]
|
||
- auth_config: dict
|
||
- auth_type: str
|
||
- resources: list[str] | None
|
||
- request_fn: 带 429 退避的 HTTP 请求函数
|
||
|
||
OAuth2 模式需将 ``token_url`` 注入 ``auth_config``(供框架级插件刷新)。
|
||
"""
|
||
# OAuth2 模式需将 token_url 注入 auth_config(供框架级插件刷新)
|
||
auth_config = dict(system_config.get("auth_config") or {})
|
||
if auth_config.get("auth_type") == "oauth2_authorization_code":
|
||
base_url = _resolve_base_url(system_config)
|
||
endpoints = resolve_oauth_endpoints(base_url)
|
||
auth_config.setdefault("token_url", endpoints["token_url"])
|
||
|
||
return {
|
||
"base_url": _resolve_base_url(system_config),
|
||
"auth_headers": _build_auth_headers(system_config),
|
||
"auth_config": auth_config,
|
||
"auth_type": system_config.get("auth_type", "basic"),
|
||
"resources": _resolve_resources(system_config),
|
||
"request_fn": _make_request_fn(),
|
||
}
|