116 lines
4.7 KiB
Python
116 lines
4.7 KiB
Python
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)}"
|