This commit is contained in:
2026-07-26 17:57:44 +08:00
parent a555f785ba
commit 069f1bafe1
27 changed files with 4893 additions and 3846 deletions
+115
View File
@@ -0,0 +1,115 @@
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from urllib.parse import quote, urlsplit
class APIConfigError(ValueError):
"""API 配置缺失或格式不合法。"""
class APIEndpointRegistry:
"""将服务地址与端点相对路径分离。"""
def __init__(self, config: Mapping[str, Any]):
self._services = config.get("_services", {})
self._endpoints = {
key: value for key, value in config.items() if not str(key).startswith("_")
}
self._validate()
@classmethod
def from_file(cls, config_path: str | Path) -> "APIEndpointRegistry":
path = Path(config_path)
try:
with path.open("r", encoding="utf-8") as file:
config = json.load(file)
except FileNotFoundError as exc:
raise APIConfigError(f"API 配置文件不存在: {path}") from exc
except json.JSONDecodeError as exc:
raise APIConfigError(
f"API 配置 JSON 不合法: {path}, line={exc.lineno}"
) from exc
if not isinstance(config, Mapping):
raise APIConfigError("API 配置根节点必须是对象")
return cls(config)
def _validate(self) -> None:
if not isinstance(self._services, Mapping):
raise APIConfigError("_services 必须是对象")
for service_name, service in self._services.items():
if not isinstance(service, Mapping):
raise APIConfigError(f"服务配置必须是对象: {service_name}")
base_url = str(service.get("base_url", "")).strip()
parsed = urlsplit(base_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise APIConfigError(f"服务 base_url 不合法: {service_name}={base_url}")
adapter = str(service.get("adapter", "generic")).lower()
if adapter not in {"generic", "jx3api", "jx3box"}:
raise APIConfigError(f"服务 adapter 不受支持: {service_name}={adapter}")
for endpoint_name, endpoint in self._endpoints.items():
if not isinstance(endpoint, Mapping):
raise APIConfigError(f"端点配置必须是对象: {endpoint_name}")
method = str(endpoint.get("method", "GET")).upper()
if method not in {"GET", "POST"}:
raise APIConfigError(
f"端点 method 仅支持 GET/POST: {endpoint_name}={method}"
)
params = endpoint.get("params", {})
if not isinstance(params, Mapping):
raise APIConfigError(f"端点 params 必须是对象: {endpoint_name}")
if "retry" in endpoint and not isinstance(endpoint["retry"], bool):
raise APIConfigError(f"端点 retry 必须是布尔值: {endpoint_name}")
service_name = endpoint.get("service")
path = endpoint.get("path")
if service_name not in self._services:
raise APIConfigError(
f"端点引用了不存在的服务: {endpoint_name} -> {service_name}"
)
if not isinstance(path, str) or not path.strip():
raise APIConfigError(f"端点缺少相对路径: {endpoint_name}")
def get(self, endpoint_name: str) -> Mapping[str, Any]:
endpoint = self._endpoints.get(endpoint_name)
if endpoint is None:
raise APIConfigError(f"未找到 API 端点: {endpoint_name}")
return endpoint
def get_service(self, service_name: str) -> Mapping[str, Any]:
service = self._services.get(service_name)
if service is None:
raise APIConfigError(f"未找到 API 服务: {service_name}")
return service
def resolve(
self,
endpoint_name: str,
path_params: Mapping[str, Any] | None = None,
) -> str:
endpoint = self.get(endpoint_name)
service_name = str(endpoint["service"])
service = self._services[service_name]
base_url = str(service["base_url"]).rstrip("/")
path_prefix = str(service.get("path_prefix", "")).strip("/")
endpoint_path = str(endpoint["path"]).lstrip("/")
encoded_params = {
key: quote(str(value), safe="")
for key, value in (path_params or {}).items()
}
try:
endpoint_path = endpoint_path.format_map(encoded_params)
except KeyError as exc:
raise APIConfigError(
f"端点 {endpoint_name} 缺少路径参数: {exc.args[0]}"
) from exc
path_parts = [part for part in (path_prefix, endpoint_path) if part]
return f"{base_url}/{'/'.join(path_parts)}"