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)}"