420 lines
18 KiB
Python
420 lines
18 KiB
Python
"""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()
|
||
|
||
DEFAULT_QUERY_GUIDANCE = (
|
||
"产量、良率、缺陷、库存、绩效、趋势和按日/月汇总等统计聚合查询,"
|
||
"统一先调用 BI dataset list,再执行匹配的数据集。日志和业务明细列表用于"
|
||
"用户明确要求查看逐条记录、编号或追溯过程的场景。未匹配到 dataset 时,"
|
||
"先限定范围或向用户确认明细查询需求。"
|
||
)
|
||
DEFAULT_RECOMMENDED_OPERATIONS = ("bi_dataset_list", "bi_dataset_exec")
|
||
|
||
|
||
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
|
||
max_total_result_bytes: int
|
||
max_page_size: int
|
||
verify_tls: bool
|
||
query_guidance: str
|
||
recommended_operation_ids: tuple[str, ...]
|
||
|
||
@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())
|
||
guidance = str(data.get("query_guidance") or DEFAULT_QUERY_GUIDANCE).strip()
|
||
if len(guidance) > 4000:
|
||
raise FactoryMesError("query_guidance 不能超过 4000 字符")
|
||
raw_recommended = data.get(
|
||
"recommended_operation_ids", DEFAULT_RECOMMENDED_OPERATIONS
|
||
)
|
||
if isinstance(raw_recommended, str):
|
||
raw_recommended = raw_recommended.split(",")
|
||
if not isinstance(raw_recommended, (list, tuple, set)):
|
||
raise FactoryMesError("recommended_operation_ids 必须是字符串数组")
|
||
recommended = tuple(dict.fromkeys(
|
||
str(item).strip() for item in raw_recommended if str(item).strip()
|
||
))
|
||
if len(recommended) > 30 or any(len(item) > 200 for item in recommended):
|
||
raise FactoryMesError("recommended_operation_ids 最多 30 项且每项不超过 200 字符")
|
||
max_result = max(4096, min(int(data.get("max_result_bytes", 65536)), 1048576))
|
||
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_result,
|
||
max_total_result_bytes=max(
|
||
max_result,
|
||
min(int(data.get("max_total_result_bytes", 262144)), 4194304),
|
||
),
|
||
max_page_size=max(1, min(int(data.get("max_page_size", 200)), 1000)),
|
||
verify_tls=_bool_value(data.get("verify_tls"), True),
|
||
query_guidance=guidance,
|
||
recommended_operation_ids=recommended,
|
||
)
|
||
|
||
|
||
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 = list(dict.fromkeys(
|
||
[query] + [x for x in re.split(r"[\s,,。/]+", query) if len(x) >= 2]
|
||
))
|
||
recommended_order = {
|
||
operation_id: index
|
||
for index, operation_id in enumerate(self.cfg.recommended_operation_ids)
|
||
}
|
||
scored: list[tuple[int, int, dict[str, Any]]] = []
|
||
for op in self._operations(spec):
|
||
method = op["method"].lower()
|
||
if method not in {"get", "head"} and not (
|
||
method == "post"
|
||
and op["operation_id"] in self.cfg.allowed_post_operations
|
||
):
|
||
continue
|
||
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)
|
||
recommended = op["operation_id"] in recommended_order
|
||
if score or recommended:
|
||
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)
|
||
compact["recommended"] = recommended
|
||
scored.append((
|
||
0 if recommended else 1,
|
||
recommended_order.get(op["operation_id"], -score),
|
||
compact,
|
||
))
|
||
scored.sort(key=lambda item: (item[0], item[1], item[2]["operation_id"]))
|
||
return [item[2] 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":
|
||
if name == "page_size":
|
||
try:
|
||
value = max(1, min(int(value), self.cfg.max_page_size))
|
||
except (TypeError, ValueError) as exc:
|
||
raise FactoryMesError("page_size 必须是整数") from exc
|
||
elif name == "page" and str(value).strip() == "0":
|
||
raise FactoryMesError(
|
||
"外部系统查询不允许 page=0 关闭分页,请使用 dataset 或分页查看明细"
|
||
)
|
||
elif name == "pageoff" and _bool_value(value, False):
|
||
raise FactoryMesError(
|
||
"外部系统查询不允许关闭分页,请使用 dataset 或分页查看明细"
|
||
)
|
||
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,
|
||
}
|