新增六边形架构核心代码包,包含: 1. 协议适配器层:HTTP/SMTP/IMAP/SSH/GRPC等多协议适配器实现 2. 认证插件体系:基础认证、API密钥、HMAC等多类型认证插件 3. 执行编排框架:工具执行器、上下文构建、运行时治理组件 4. 用例端口与DTO:定义领域服务端口与数据传输对象 5. 厂商集成包框架:支持第三方系统集成扩展 6. 基础设施装配层:实现依赖注入与服务装配 所有代码遵循六边形架构设计原则,实现端口与适配器解耦,支持动态扩展与自动发现。
513 lines
20 KiB
Python
513 lines
20 KiB
Python
"""HubSpot discover / preview_tools / create_tools handler 实现 + 元数据发现 +
|
||
Search 渲染 + 错误提取器。
|
||
|
||
handler 签名遵循 ``OperationHandler`` 协议:
|
||
``(source_type: str, system_config: dict[str, Any]) -> Awaitable[Any]``。
|
||
|
||
handler 内部职责:
|
||
1. 从 ``system_config`` 提取 ``base_url`` / ``auth_config`` / ``_resolved_token``
|
||
2. 构建 ``auth_headers``(token 由 use_cases 层注入到 ``system_config["_resolved_token"]``)
|
||
3. 构造带 429 退避的 ``request_fn``,发起元数据发现请求
|
||
4. 调用 ``HubSpotConfigGenerator`` 生成工具配置草稿
|
||
|
||
两层错误处理(见设计文档 §8):
|
||
|
||
- **L1**: ``extract_hubspot_error`` — 注册到 ``http_error_extractor_registry``,
|
||
由框架 executor 在运行时工具执行失败时调用,提取友好错误消息字符串。
|
||
- **L2**: ``map_hubspot_error`` — 不注册到任何注册表,仅在 handler 内部
|
||
discover/preview/create 阶段失败后调用,将 HTTP 状态码映射为
|
||
``ExternalSystemError`` 子类。
|
||
|
||
429 退避分层(见设计文档 §6.4):
|
||
- discover/preview/create 阶段:本模块 ``_request_with_retry`` 实现
|
||
- 运行时工具执行:通过工具 ``retry_policy.retry_status_codes: [429]`` 配置
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from collections.abc import Awaitable, Callable
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from yuxi.external_systems.exceptions import (
|
||
AccessDeniedError,
|
||
AuthError,
|
||
EntityNotFoundError,
|
||
ExecutionError,
|
||
QuotaExceededError,
|
||
RateLimitExceededError,
|
||
)
|
||
from yuxi.external_systems.integrations.hubspot.generators import (
|
||
HubSpotConfigGenerator,
|
||
)
|
||
from yuxi.external_systems.integrations.hubspot.metadata import (
|
||
API_BASE_URL,
|
||
ASSOCIATIONS_PATH_TEMPLATE,
|
||
DEFAULT_STANDARD_OBJECTS,
|
||
PROPERTIES_PATH_TEMPLATE,
|
||
SCHEMAS_PATH,
|
||
STANDARD_OBJECT_TYPE_IDS,
|
||
)
|
||
from yuxi.external_systems.integrations.schemas import GeneratedToolsDraft
|
||
|
||
if TYPE_CHECKING:
|
||
import httpx
|
||
|
||
# discover/preview/create 阶段 429 退避参数
|
||
_RETRY_MAX_ATTEMPTS = 5
|
||
_RETRY_BASE_DELAY = 1.0
|
||
|
||
# HubSpot 通用请求头(除 Authorization 外)
|
||
_HUBSPOT_HEADERS: dict[str, str] = {
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
|
||
# ── 错误提取器(L1)──────────────────────────────────────────────────────
|
||
|
||
|
||
def extract_hubspot_error(response: httpx.Response) -> str | None:
|
||
"""从 HubSpot 错误响应中提取友好错误信息(L1)。
|
||
|
||
HubSpot 错误响应为 JSON 对象,含顶层 ``status`` / ``message`` / ``correlationId``
|
||
与 ``errors`` 数组::
|
||
|
||
{
|
||
"status": "error",
|
||
"message": "Property values were not valid",
|
||
"correlationId": "a1b2c3d4-e5f6-7890",
|
||
"errors": [
|
||
{
|
||
"message": "The property 'email' is required",
|
||
"in": "propertyName",
|
||
"code": "REQUIRED_PROPERTY",
|
||
"context": {"propertyName": ["email"]}
|
||
}
|
||
],
|
||
"category": "VALIDATION_ERROR"
|
||
}
|
||
|
||
Returns:
|
||
拼接后的错误消息字符串(含 message + errors[].code/message + correlationId),
|
||
非 HubSpot 错误格式时返回 None。
|
||
"""
|
||
try:
|
||
body = response.json()
|
||
except Exception:
|
||
return None
|
||
|
||
if not isinstance(body, dict):
|
||
return None
|
||
|
||
if body.get("status") != "error":
|
||
return None
|
||
|
||
parts: list[str] = []
|
||
top_message = body.get("message", "")
|
||
if top_message:
|
||
parts.append(top_message)
|
||
|
||
errors = body.get("errors") or []
|
||
for err in errors:
|
||
if not isinstance(err, dict):
|
||
continue
|
||
code = err.get("code", "UNKNOWN")
|
||
msg = err.get("message", "")
|
||
context = err.get("context") or {}
|
||
context_hint = f" (context: {context})" if context else ""
|
||
parts.append(f"[{code}] {msg}{context_hint}")
|
||
|
||
correlation_id = body.get("correlationId")
|
||
if correlation_id:
|
||
parts.append(f"correlationId: {correlation_id}")
|
||
|
||
return "; ".join(parts) if parts else None
|
||
|
||
|
||
def map_hubspot_error(status_code: int, error_body: dict[str, Any]) -> Exception:
|
||
"""将 HubSpot 错误响应转换为 ExternalSystemError 子类(L2)。
|
||
|
||
仅在 handler 内部(discover/preview/create 阶段)使用,不注册到框架。
|
||
运行时工具执行的错误由框架 executor 统一包装为 ExecutionError。
|
||
|
||
错误码映射见设计文档 §8.3。
|
||
"""
|
||
message = error_body.get("message", "") if isinstance(error_body, dict) else ""
|
||
category = error_body.get("category", "") if isinstance(error_body, dict) else ""
|
||
correlation_id = error_body.get("correlationId", "") if isinstance(error_body, dict) else ""
|
||
context_hint = f" (correlationId={correlation_id})" if correlation_id else ""
|
||
full_message = f"HubSpot: {message}{context_hint}" if message else f"HubSpot 错误{context_hint}"
|
||
|
||
if status_code == 401:
|
||
return AuthError(f"HubSpot 认证失败: {full_message} (category={category})")
|
||
if status_code == 403:
|
||
return AccessDeniedError(f"HubSpot 访问被拒: {full_message} (category={category})")
|
||
if status_code == 404:
|
||
return EntityNotFoundError(f"HubSpot 资源不存在: {full_message}")
|
||
if status_code == 409:
|
||
return ExecutionError(f"HubSpot 冲突: {full_message}")
|
||
if status_code == 429:
|
||
# RATE_LIMIT_EXCEEDED vs DAILY_LIMIT_EXCEEDED 按 category 区分
|
||
if category == "DAILY_LIMIT_EXCEEDED":
|
||
return QuotaExceededError(f"HubSpot 每日配额耗尽: {full_message}")
|
||
return RateLimitExceededError(
|
||
f"HubSpot 限流: {full_message}",
|
||
retry_after=60,
|
||
limit_type="qps",
|
||
)
|
||
if 500 <= status_code < 600:
|
||
return ExecutionError(f"HubSpot 服务端错误: {full_message} (status={status_code})")
|
||
return ExecutionError(f"HubSpot 未知错误: {full_message} (status={status_code})")
|
||
|
||
|
||
# ── Search API 请求体渲染 ─────────────────────────────────────────────────
|
||
|
||
|
||
def _render_search_body(
|
||
filter_groups: list[dict[str, Any]],
|
||
properties: list[str] | None = None,
|
||
sorts: list[dict[str, Any]] | None = None,
|
||
after: str | None = None,
|
||
limit: int = 50,
|
||
) -> dict[str, Any]:
|
||
"""渲染 HubSpot Search API 请求体。
|
||
|
||
Args:
|
||
filter_groups: 筛选组列表,每组内 filters 为 AND 关系,组间为 OR 关系。
|
||
每条 filter 含 propertyName / operator / value。
|
||
operator 支持: EQ / NEQ / LT / LTE / GT / GTE / BETWEEN / IN / NOT_IN /
|
||
CONTAINS_TOKEN / NOT_CONTAINS_TOKEN / HAS_PROPERTY / NOT_HAS_PROPERTY。
|
||
properties: 返回的属性列表(默认返回全部)。
|
||
sorts: 排序规则列表。
|
||
after: 分页游标(上一页响应的 after 值)。
|
||
limit: 每页记录数(最大 200)。
|
||
"""
|
||
body: dict[str, Any] = {"filterGroups": filter_groups}
|
||
if properties:
|
||
body["properties"] = properties
|
||
if sorts:
|
||
body["sorts"] = sorts
|
||
paging: dict[str, Any] = {"limit": min(limit, 200)}
|
||
if after:
|
||
paging["after"] = after
|
||
body["paging"] = paging
|
||
return body
|
||
|
||
|
||
# ── system_config 解析辅助 ────────────────────────────────────────────────
|
||
|
||
|
||
def _build_auth_headers(system_config: dict[str, Any]) -> dict[str, str]:
|
||
"""从 system_config 提取 token,构建 HubSpot 请求头。
|
||
|
||
system_config 中 token 由 use_cases 层通过 ``token_manager.get_token`` 预处理后
|
||
注入到 ``system_config["_resolved_token"]["access_token"]``。handler 不自行换取
|
||
token——token 获取是 use_cases 层的职责。
|
||
"""
|
||
token_info = system_config.get("_resolved_token")
|
||
if not isinstance(token_info, dict) or not token_info.get("access_token"):
|
||
raise AuthError("hubspot: system_config 缺少 _resolved_token,use_cases 层未注入 token")
|
||
access_token = token_info["access_token"]
|
||
headers = dict(_HUBSPOT_HEADERS)
|
||
headers["Authorization"] = f"Bearer {access_token}"
|
||
return headers
|
||
|
||
|
||
def _resolve_base_url(system_config: dict[str, Any]) -> str:
|
||
"""从 system_config 提取 base_url,默认 ``https://api.hubapi.com``。"""
|
||
connection_config = system_config.get("connection_config") or {}
|
||
base_url = connection_config.get("base_url") or API_BASE_URL
|
||
return str(base_url).rstrip("/")
|
||
|
||
|
||
def _resolve_auth_type(system_config: dict[str, Any]) -> str:
|
||
"""从 system_config 提取 auth_type,默认 private_app_token。"""
|
||
return str(system_config.get("auth_type", "private_app_token"))
|
||
|
||
|
||
def _resolve_selected_objects(system_config: dict[str, Any]) -> list[str]:
|
||
"""从 system_config 提取用户选择的标准对象 FQN 列表。
|
||
|
||
默认返回 ``DEFAULT_STANDARD_OBJECTS``(contacts / companies / deals / tickets)。
|
||
"""
|
||
discovery_options = system_config.get("discovery_options") or {}
|
||
selected = discovery_options.get("selected_objects")
|
||
if selected:
|
||
return list(selected)
|
||
return list(DEFAULT_STANDARD_OBJECTS)
|
||
|
||
|
||
def _resolve_association_pairs(
|
||
system_config: dict[str, Any],
|
||
) -> list[tuple[str, str]]:
|
||
"""从 system_config 提取需要发现关联标签的对象对列表。
|
||
|
||
返回 ``[(from_type, to_type), ...]``,默认空列表(不发现关联)。
|
||
``discovery_options.association_pairs`` 格式:``[{"from": "0-1", "to": "0-2"}]``。
|
||
"""
|
||
discovery_options = system_config.get("discovery_options") or {}
|
||
pairs_raw = discovery_options.get("association_pairs") or []
|
||
pairs: list[tuple[str, str]] = []
|
||
for pair in pairs_raw:
|
||
if not isinstance(pair, dict):
|
||
continue
|
||
from_type = pair.get("from")
|
||
to_type = pair.get("to")
|
||
if from_type and to_type:
|
||
pairs.append((str(from_type), str(to_type)))
|
||
return pairs
|
||
|
||
|
||
# ── 429 退避 HTTP 请求 ────────────────────────────────────────────────────
|
||
|
||
|
||
async def _request_with_retry(
|
||
method: str,
|
||
url: str,
|
||
headers: dict[str, str],
|
||
*,
|
||
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_hubspot_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)
|
||
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"HubSpot 限流,已重试 {max_retries} 次仍失败: {url}",
|
||
retry_after=retry_after,
|
||
limit_type="qps",
|
||
)
|
||
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"HubSpot 网络请求失败: {exc}") from exc
|
||
|
||
assert last_response is not None
|
||
if last_response.is_success:
|
||
return last_response
|
||
try:
|
||
error_body = last_response.json()
|
||
except Exception:
|
||
error_body = {"message": last_response.text}
|
||
raise map_hubspot_error(last_response.status_code, error_body)
|
||
|
||
|
||
def _make_request_fn() -> Callable[..., Awaitable[httpx.Response]]:
|
||
"""构造带 429 退避的 request_fn,供元数据发现函数使用。"""
|
||
|
||
async def request_fn(method: str, url: str, headers: dict[str, str]) -> httpx.Response:
|
||
return await _request_with_retry(method, url, headers)
|
||
|
||
return request_fn
|
||
|
||
|
||
# ── 元数据发现函数 ────────────────────────────────────────────────────────
|
||
|
||
|
||
async def _discover_schemas(
|
||
request_fn: Callable[..., Awaitable[httpx.Response]],
|
||
base_url: str,
|
||
headers: dict[str, str],
|
||
) -> list[dict[str, Any]]:
|
||
"""调用 ``GET /crm/v3/schemas`` 获取自定义对象 schema 列表。
|
||
|
||
自定义对象类型 ID 为 ``2-XXX``;标准对象 schema 不在此处列出。
|
||
"""
|
||
response = await request_fn("GET", f"{base_url}{SCHEMAS_PATH}", headers)
|
||
data = response.json()
|
||
return data.get("results", []) if isinstance(data, dict) else []
|
||
|
||
|
||
async def _discover_properties(
|
||
request_fn: Callable[..., Awaitable[httpx.Response]],
|
||
base_url: str,
|
||
object_type_id: str,
|
||
headers: dict[str, str],
|
||
) -> list[dict[str, Any]]:
|
||
"""调用 ``GET /crm/v3/properties/{objectTypeId}`` 获取对象属性元数据。"""
|
||
path = PROPERTIES_PATH_TEMPLATE.format(object_type_id=object_type_id)
|
||
response = await request_fn("GET", f"{base_url}{path}", headers)
|
||
data = response.json()
|
||
return data.get("results", []) if isinstance(data, dict) else []
|
||
|
||
|
||
async def _discover_association_labels(
|
||
request_fn: Callable[..., Awaitable[httpx.Response]],
|
||
base_url: str,
|
||
from_type: str,
|
||
to_type: str,
|
||
headers: dict[str, str],
|
||
) -> list[dict[str, Any]]:
|
||
"""调用 ``GET /crm/v4/associations/{from}/{to}/labels`` 获取关联类型标签。"""
|
||
path = ASSOCIATIONS_PATH_TEMPLATE.format(**{"from": from_type, "to": to_type})
|
||
response = await request_fn("GET", f"{base_url}{path}", headers)
|
||
data = response.json()
|
||
return data.get("results", []) if isinstance(data, dict) else []
|
||
|
||
|
||
# ── 三个 OperationHandler 实现 ────────────────────────────────────────────
|
||
|
||
|
||
async def discover_hubspot(
|
||
source_type: str,
|
||
system_config: dict[str, Any],
|
||
) -> list[dict[str, Any]]:
|
||
"""discover handler:发现 HubSpot 可用对象列表(标准对象 + 自定义对象 Schemas)。
|
||
|
||
Returns:
|
||
对象摘要列表,每项含 ``object_type_id`` / ``name`` / ``is_standard`` /
|
||
``label``。
|
||
"""
|
||
base_url = _resolve_base_url(system_config)
|
||
auth_headers = _build_auth_headers(system_config)
|
||
selected_objects = _resolve_selected_objects(system_config)
|
||
request_fn = _make_request_fn()
|
||
|
||
# 标准对象(用户选择的)
|
||
results: list[dict[str, Any]] = []
|
||
for fqn in selected_objects:
|
||
type_id = STANDARD_OBJECT_TYPE_IDS.get(fqn, fqn)
|
||
results.append(
|
||
{
|
||
"object_type_id": type_id,
|
||
"name": fqn,
|
||
"label": fqn.replace("_", " ").title(),
|
||
"is_standard": True,
|
||
}
|
||
)
|
||
|
||
# 自定义对象(Schemas API)
|
||
custom_schemas = await _discover_schemas(request_fn, base_url, auth_headers)
|
||
for schema in custom_schemas:
|
||
type_id = schema.get("objectTypeId", "")
|
||
name = schema.get("name", type_id)
|
||
labels = schema.get("labels") or {}
|
||
label = labels.get("singular", name) if isinstance(labels, dict) else name
|
||
results.append(
|
||
{
|
||
"object_type_id": type_id,
|
||
"name": name,
|
||
"label": label,
|
||
"is_standard": False,
|
||
}
|
||
)
|
||
|
||
return results
|
||
|
||
|
||
async def preview_hubspot_tools(
|
||
source_type: str,
|
||
system_config: dict[str, Any],
|
||
) -> list[dict[str, Any]]:
|
||
"""preview_tools handler:预览将生成的工具列表。
|
||
|
||
Returns:
|
||
工具预览列表,每项为 ExternalToolCreateInput 兼容的 dict。
|
||
"""
|
||
payload = await _build_generator_payload(system_config)
|
||
generator = HubSpotConfigGenerator()
|
||
return await generator.generate(payload)
|
||
|
||
|
||
async def create_hubspot_tools(
|
||
source_type: str,
|
||
system_config: dict[str, Any],
|
||
) -> GeneratedToolsDraft:
|
||
"""create_tools handler:生成工具草稿(不直接持久化)。
|
||
|
||
Returns:
|
||
GeneratedToolsDraft,由 use_cases 层统一持久化。
|
||
"""
|
||
payload = await _build_generator_payload(system_config)
|
||
generator = HubSpotConfigGenerator()
|
||
tool_configs = await generator.generate(payload)
|
||
return GeneratedToolsDraft(tool_configs=tool_configs, override_existing=False)
|
||
|
||
|
||
# ── 生成器 payload 构造 ───────────────────────────────────────────────────
|
||
|
||
|
||
async def _build_generator_payload(system_config: dict[str, Any]) -> dict[str, Any]:
|
||
"""从 system_config 构造生成器 payload。
|
||
|
||
流程:
|
||
1. 解析 base_url / auth_headers / auth_type / auth_config
|
||
2. 解析用户选择的标准对象 FQN 列表
|
||
3. 发现自定义对象 Schemas
|
||
4. 为每个对象(标准 + 自定义)发现属性元数据
|
||
5. 可选:发现关联标签(基于 discovery_options.association_pairs)
|
||
6. 汇总为生成器 payload
|
||
"""
|
||
base_url = _resolve_base_url(system_config)
|
||
auth_headers = _build_auth_headers(system_config)
|
||
auth_type = _resolve_auth_type(system_config)
|
||
auth_config = system_config.get("auth_config") or {}
|
||
selected_objects = _resolve_selected_objects(system_config)
|
||
association_pairs = _resolve_association_pairs(system_config)
|
||
request_fn = _make_request_fn()
|
||
|
||
# 发现自定义对象 Schemas
|
||
custom_schemas = await _discover_schemas(request_fn, base_url, auth_headers)
|
||
|
||
# 合并标准对象与自定义对象 → (objectTypeId, fqn) 列表
|
||
all_objects: list[tuple[str, str]] = []
|
||
for fqn in selected_objects:
|
||
type_id = STANDARD_OBJECT_TYPE_IDS.get(fqn, fqn)
|
||
all_objects.append((type_id, fqn))
|
||
for schema in custom_schemas:
|
||
type_id = schema.get("objectTypeId", "")
|
||
fqn = schema.get("name", type_id)
|
||
if type_id:
|
||
all_objects.append((type_id, fqn))
|
||
|
||
# 为每个对象发现属性元数据
|
||
properties_map: dict[str, list[dict[str, Any]]] = {}
|
||
for type_id, _fqn in all_objects:
|
||
properties_map[type_id] = await _discover_properties(request_fn, base_url, type_id, auth_headers)
|
||
|
||
# 可选:发现关联标签
|
||
association_labels: list[dict[str, Any]] = []
|
||
for from_type, to_type in association_pairs:
|
||
labels = await _discover_association_labels(request_fn, base_url, from_type, to_type, auth_headers)
|
||
if labels:
|
||
association_labels.append({"from": from_type, "to": to_type, "labels": labels})
|
||
|
||
return {
|
||
"standard_objects": selected_objects,
|
||
"custom_schemas": custom_schemas,
|
||
"properties_map": properties_map,
|
||
"association_labels": association_labels,
|
||
"base_url": base_url,
|
||
"auth_type": auth_type,
|
||
"auth_config": auth_config,
|
||
}
|