294 lines
12 KiB
Python
294 lines
12 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()
|
||
|
||
|
||
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 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 = urljoin(self.cfg.base_url + "/", path.lstrip("/"))
|
||
base_origin = urlparse(self.cfg.base_url)
|
||
call_origin = urlparse(url)
|
||
if (call_origin.scheme, call_origin.netloc) != (base_origin.scheme, base_origin.netloc):
|
||
raise FactoryMesError("接口目标越出 Factory MES 主机")
|
||
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,
|
||
}
|