zcbot/core/external_systems/factory.py

354 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Factory MES OpenAPI connector。
目标地址全部来自管理员维护的可信系统目录;模型和普通用户只能传 operation_id
与结构化参数,不能传 URL。
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass
from threading import Lock
from typing import Any, Optional
from urllib.parse import quote, urljoin, urlparse
import httpx
class FactoryMesError(RuntimeError):
pass
_HTTP_METHODS = ("get", "head", "post", "put", "patch", "delete")
_SPEC_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
_SPEC_LOCK = Lock()
def _bool_value(value: Any, default: bool) -> bool:
raw = str(value if value is not None else "").strip().lower()
if not raw:
return default
return raw in {"1", "true", "yes", "on"}
def _validated_http_url(raw: str, label: str) -> str:
value = (raw or "").strip().rstrip("/")
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise FactoryMesError(f"{label} 必须是有效的 http(s) URL")
if parsed.username or parsed.password:
raise FactoryMesError(f"{label} 不能内嵌凭据")
return value
@dataclass(frozen=True)
class FactoryMesConfig:
base_url: str
openapi_url: str
login_path: str
allowed_post_operations: frozenset[str]
timeout_seconds: float
max_result_bytes: int
verify_tls: bool
@classmethod
def from_mapping(cls, data: dict[str, Any]) -> "FactoryMesConfig":
"""从管理员保存的可信目录配置构建运行态配置。"""
base = _validated_http_url(str(data.get("base_url") or ""), "base_url")
spec = _validated_http_url(
str(data.get("openapi_url") or ""), "openapi_url"
)
login_path = str(data.get("login_path") or "/api/auth/token/").strip()
if not login_path.startswith("/") or "://" in login_path:
raise FactoryMesError("login_path 必须是站内绝对路径")
raw_allowed = data.get("allowed_post_operations") or []
if isinstance(raw_allowed, str):
raw_allowed = raw_allowed.split(",")
if not isinstance(raw_allowed, (list, tuple, set)):
raise FactoryMesError("allowed_post_operations 必须是字符串数组")
allowed = frozenset(str(item).strip() for item in raw_allowed if str(item).strip())
return cls(
base_url=base,
openapi_url=spec,
login_path=login_path,
allowed_post_operations=allowed,
timeout_seconds=max(1.0, min(float(data.get("timeout_seconds", 15)), 60.0)),
max_result_bytes=max(4096, min(int(data.get("max_result_bytes", 65536)), 1048576)),
verify_tls=_bool_value(data.get("verify_tls"), True),
)
class FactoryMesClient:
def __init__(self, username: str, password: str, cfg: FactoryMesConfig):
self.username = username
self.password = password
self.cfg = cfg
def _client(self) -> httpx.Client:
return httpx.Client(
timeout=self.cfg.timeout_seconds,
verify=self.cfg.verify_tls,
follow_redirects=False,
)
def authenticate(self) -> str:
url = urljoin(self.cfg.base_url + "/", self.cfg.login_path.lstrip("/"))
try:
with self._client() as client:
response = client.post(
url,
json={"username": self.username, "password": self.password},
)
except httpx.HTTPError as exc:
raise FactoryMesError(f"Factory MES 登录连接失败: {type(exc).__name__}") from exc
if response.status_code >= 400:
raise FactoryMesError(f"Factory MES 登录失败(HTTP {response.status_code})")
try:
token = response.json().get("access", "")
except (ValueError, AttributeError):
token = ""
if not isinstance(token, str) or not token:
raise FactoryMesError("Factory MES 登录响应缺少 access token")
return token
def _fetch_spec(self, token: str) -> dict[str, Any]:
now = time.monotonic()
with _SPEC_LOCK:
hit = _SPEC_CACHE.get(self.cfg.openapi_url)
if hit and now - hit[0] < 300:
return hit[1]
try:
with self._client() as client:
response = client.get(
self.cfg.openapi_url,
headers={"Authorization": f"Bearer {token}"},
)
except httpx.HTTPError as exc:
raise FactoryMesError(f"Factory OpenAPI 获取失败: {type(exc).__name__}") from exc
if response.status_code >= 400:
raise FactoryMesError(f"Factory OpenAPI 获取失败(HTTP {response.status_code})")
try:
spec = response.json()
except ValueError as exc:
raise FactoryMesError("Factory OpenAPI 不是有效 JSON") from exc
if not isinstance(spec, dict) or not isinstance(spec.get("paths"), dict):
raise FactoryMesError("Factory OpenAPI 缺少 paths")
with _SPEC_LOCK:
_SPEC_CACHE[self.cfg.openapi_url] = (now, spec)
return spec
@staticmethod
def _operation_id(method: str, path: str, operation: dict[str, Any]) -> str:
explicit = operation.get("operationId")
if isinstance(explicit, str) and explicit.strip():
return explicit.strip()
safe_path = re.sub(r"[^a-zA-Z0-9]+", "_", path).strip("_")
return f"{method}_{safe_path}"
@classmethod
def _operations(cls, spec: dict[str, Any]) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
for path, path_item in (spec.get("paths") or {}).items():
if not isinstance(path_item, dict):
continue
common = path_item.get("parameters") or []
for method in _HTTP_METHODS:
operation = path_item.get(method)
if not isinstance(operation, dict):
continue
params = list(common) + list(operation.get("parameters") or [])
results.append({
"operation_id": cls._operation_id(method, path, operation),
"method": method.upper(),
"path": path,
"summary": operation.get("summary") or "",
"description": operation.get("description") or "",
"tags": operation.get("tags") or [],
"parameters": params,
"request_body": operation.get("requestBody"),
})
return results
def _spec_base_path(self, spec: dict[str, Any]) -> str:
"""Return the API path prefix declared by Swagger 2 / OpenAPI 3.
The remote specification may describe a different host, but an external
system definition is the only authority allowed to choose the target
origin. OpenAPI ``servers`` therefore contributes only a same-origin
path prefix.
"""
raw_base_path = spec.get("basePath")
if raw_base_path is not None:
if not isinstance(raw_base_path, str) or not raw_base_path.startswith("/"):
raise FactoryMesError("Swagger basePath 必须是站内绝对路径")
parsed = urlparse(raw_base_path)
if parsed.netloc or parsed.query or parsed.fragment or "://" in raw_base_path:
raise FactoryMesError("Swagger basePath 非法")
return parsed.path.rstrip("/")
servers = spec.get("servers")
if not isinstance(servers, list) or not servers:
return ""
server = servers[0]
raw_url = server.get("url") if isinstance(server, dict) else None
if not isinstance(raw_url, str) or not raw_url.strip():
raise FactoryMesError("OpenAPI server URL 无效")
raw_url = raw_url.strip()
if "{" in raw_url or "}" in raw_url:
raise FactoryMesError("OpenAPI server URL 包含未解析变量")
declared = urlparse(raw_url)
base = urlparse(self.cfg.base_url)
if declared.netloc and (declared.scheme, declared.netloc) != (
base.scheme,
base.netloc,
):
raise FactoryMesError("OpenAPI server 越出 Factory MES 主机")
if declared.query or declared.fragment:
raise FactoryMesError("OpenAPI server URL 不能包含查询或片段")
return ("/" + declared.path.lstrip("/")).rstrip("/")
def _operation_url(self, spec: dict[str, Any], operation_path: str) -> str:
prefix = self._spec_base_path(spec)
base = urlparse(self.cfg.base_url)
configured_path = base.path.rstrip("/")
path = operation_path
configured_has_prefix = bool(prefix) and (
configured_path == prefix or configured_path.endswith(prefix)
)
operation_has_prefix = bool(prefix) and (
path == prefix or path.startswith(prefix + "/")
)
if configured_has_prefix and operation_has_prefix:
path = path[len(prefix):] or "/"
elif prefix and not configured_has_prefix and not operation_has_prefix:
path = prefix + "/" + path.lstrip("/")
combined_path = "/".join(
part.strip("/") for part in (configured_path, path) if part.strip("/")
)
if operation_path.endswith("/") and combined_path:
combined_path += "/"
url = urljoin(f"{base.scheme}://{base.netloc}/", combined_path)
call_origin = urlparse(url)
if (call_origin.scheme, call_origin.netloc) != (base.scheme, base.netloc):
raise FactoryMesError("接口目标越出 Factory MES 主机")
return url
def test_connection(self) -> dict[str, Any]:
token = self.authenticate()
spec = self._fetch_spec(token)
return {"operation_count": len(self._operations(spec))}
def search(self, query: str, limit: int = 12) -> list[dict[str, Any]]:
query = (query or "").strip().lower()
if not query:
raise FactoryMesError("query 不能为空")
token = self.authenticate()
spec = self._fetch_spec(token)
terms = [query] + [x for x in re.split(r"[\s,,。/]+", query) if len(x) >= 2]
scored: list[tuple[int, dict[str, Any]]] = []
for op in self._operations(spec):
hay = " ".join([
op["operation_id"], op["path"], op["summary"], op["description"],
" ".join(str(x) for x in op["tags"]),
]).lower()
score = sum(5 if term == query and term in hay else 1 for term in terms if term in hay)
if score:
compact = dict(op)
compact["parameters"] = [
{
"name": p.get("name"),
"in": p.get("in"),
"required": bool(p.get("required")),
"type": p.get("type") or (p.get("schema") or {}).get("type"),
"description": p.get("description") or "",
}
for p in op["parameters"] if isinstance(p, dict) and "$ref" not in p
]
compact.pop("request_body", None)
scored.append((score, compact))
scored.sort(key=lambda item: (-item[0], item[1]["operation_id"]))
return [item[1] for item in scored[: max(1, min(int(limit), 30))]]
def call(
self,
operation_id: str,
arguments: Optional[dict[str, Any]] = None,
body: Any = None,
) -> dict[str, Any]:
token = self.authenticate()
spec = self._fetch_spec(token)
matches = [op for op in self._operations(spec) if op["operation_id"] == operation_id]
if len(matches) != 1:
raise FactoryMesError("operation_id 不存在或不唯一,请先搜索接口")
op = matches[0]
if not op["path"].startswith("/") or "://" in op["path"]:
raise FactoryMesError("OpenAPI operation path 非法")
method = op["method"].lower()
if method not in {"get", "head"} and not (
method == "post" and operation_id in self.cfg.allowed_post_operations
):
raise FactoryMesError(f"operation {operation_id} 未列入只读调用范围")
supplied = dict(arguments or {})
path = op["path"]
query: dict[str, Any] = {}
headers = {"Authorization": f"Bearer {token}"}
request_body = body
for param in op["parameters"]:
if not isinstance(param, dict) or "$ref" in param:
continue
name = param.get("name")
location = param.get("in")
if not isinstance(name, str):
continue
# Swagger 2 的 body 参数既可按搜索结果中的参数名放在 arguments
# 也可使用元工具独立的 body 字段;两者只取一个。
present = name in supplied or (location == "body" and request_body is not None)
if param.get("required") and not present:
raise FactoryMesError(f"缺少必填参数: {name}")
if name not in supplied:
continue
value = supplied.pop(name)
if location == "path":
path = path.replace("{" + name + "}", quote(str(value), safe=""))
elif location == "query":
query[name] = value
elif location == "body" and request_body is None:
request_body = value
if supplied:
raise FactoryMesError("存在接口定义之外的参数: " + ", ".join(sorted(supplied)))
if "{" in path or "}" in path:
raise FactoryMesError("路径参数未完整提供")
url = self._operation_url(spec, path)
try:
with self._client() as client:
response = client.request(
method.upper(),
url,
params=query,
json=request_body if method == "post" else None,
headers=headers,
)
except httpx.HTTPError as exc:
raise FactoryMesError(f"Factory 接口调用失败: {type(exc).__name__}") from exc
if response.status_code >= 400:
raise FactoryMesError(f"Factory 接口返回 HTTP {response.status_code}")
content_type = response.headers.get("content-type", "")
try:
payload: Any = response.json() if "json" in content_type else response.text
except ValueError:
payload = response.text
encoded = json.dumps(payload, ensure_ascii=False, default=str)
truncated = len(encoded.encode("utf-8")) > self.cfg.max_result_bytes
if truncated:
encoded = encoded.encode("utf-8")[: self.cfg.max_result_bytes].decode("utf-8", "ignore")
payload = encoded
return {
"operation_id": operation_id,
"status_code": response.status_code,
"truncated": truncated,
"data": payload,
}