mock_api.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. """API_PULL 对端 Mock(任务书 P1-D)。
  2. 契约严格对齐 MdpApiPullExecutor.cs 与既有 doc/db/mdp/mock_api/mock_server.py:
  3. - GET 单次请求,不分页;响应数组位于 data.list(可切换 responsePath);
  4. - 每行去重键 bizKey(dedup_key_path);?cursor=<上批最后一行 bizKey> 只返回更大行;
  5. - 鉴权模式 NONE / TOKEN(Bearer) / BASIC / APIKEY,对齐执行器 ApplyAuth 分支;
  6. - 可注入延迟与强制 HTTP 错误(401/403/429/500);
  7. - 端点复用 doc/db/mdp/mock_api/endpoints.json 与 samples/*.json(不迁移、不修改),
  8. 另支持运行时 override:把模拟器适配后的样例(带 SIM 前缀)挂到任意路径。
  9. 每次调用写入内存审计(Header 脱敏),Secret 值以 redact_text 二次遮蔽。
  10. """
  11. from __future__ import annotations
  12. import base64
  13. import json
  14. import random
  15. import sys
  16. import threading
  17. import time
  18. from collections import deque
  19. from dataclasses import dataclass, field
  20. from pathlib import Path
  21. _SIM_ROOT = Path(__file__).resolve().parents[1]
  22. if str(_SIM_ROOT) not in sys.path:
  23. sys.path.insert(0, str(_SIM_ROOT))
  24. from fastapi import FastAPI, HTTPException, Query, Request, Response # noqa: E402
  25. from catalog.sample_adapters import MOCK_API_DIR # noqa: E402
  26. from clients.redaction import redact_headers # noqa: E402
  27. @dataclass
  28. class MockApiSettings:
  29. auth_mode: str = "TOKEN" # NONE | TOKEN | BASIC | APIKEY
  30. token: str = "sim-mock-token"
  31. basic_user: str = "sim"
  32. basic_password: str = "sim-basic-password"
  33. api_key_header: str = "X-Api-Key"
  34. api_key_value: str = "sim-api-key"
  35. delay_seconds: float = 0.0
  36. force_status: int | None = None # 401/403/404/429/500/503 等
  37. response_path: str = "data.list"
  38. audit_limit: int = 200
  39. # ---- 第二阶段 N2 异常模式(内存态,恢复默认即清除)----
  40. timeout_seconds: float = 0.0 # >0 时按秒数睡眠,模拟超过 Ai-DOP 超时
  41. empty_response: bool = False # 返回 200 空 body
  42. invalid_json: bool = False # 返回非法 JSON 文本
  43. content_type: str | None = None # 覆盖响应 Content-Type(如 text/html)
  44. ignore_cursor: bool = False # 无视 cursor 返回全部行(cursor 不推进)
  45. no_sort: bool = False # 不按 bizKey 排序(cursor 乱序)
  46. # ---- 第三阶段 P2 压力模式(受控:须压力计划确认后由控制台注入)----
  47. load_mode: bool = False # 压力模式开关(§19 实时停止/恢复正常)
  48. page_size: int = 0 # >0: 每次仅返回前 N 行(配置每次返回行数)
  49. total_rows_limit: int = 0 # >0: 响应数据总量上限(模拟大表截断)
  50. random_delay: float = 0.0 # >0: 每请求随机延迟 0..random_delay 秒
  51. error_rate: float = 0.0 # 0..1: 随机 500 比例(间歇 5xx)
  52. rate_limit: float = 0.0 # >0: 每秒最大请求数,超出返回 429(限流阈值)
  53. def reset_faults(self) -> None:
  54. """恢复默认:清空全部异常注入设置(鉴权/凭据类保留)。"""
  55. self.auth_mode = "TOKEN"
  56. self.delay_seconds = 0.0
  57. self.force_status = None
  58. self.response_path = "data.list"
  59. self.timeout_seconds = 0.0
  60. self.empty_response = False
  61. self.invalid_json = False
  62. self.content_type = None
  63. self.ignore_cursor = False
  64. self.no_sort = False
  65. self.load_mode = False
  66. self.page_size = 0
  67. self.total_rows_limit = 0
  68. self.random_delay = 0.0
  69. self.error_rate = 0.0
  70. self.rate_limit = 0.0
  71. @dataclass
  72. class MockApiLoadStats:
  73. """P2 压力模式请求统计(§19:统计收到的请求量、并发、QPS、耗时和错误)。"""
  74. requests: int = 0
  75. active: int = 0 # 当前并发
  76. peak_active: int = 0 # 峰值并发
  77. status_counts: dict = field(default_factory=dict)
  78. latencies: list = field(default_factory=list)
  79. rate_limited: int = 0 # 429 次数
  80. recent_times: deque = field(default_factory=lambda: deque(maxlen=500))
  81. _lock: threading.Lock = field(default_factory=threading.Lock)
  82. def begin(self):
  83. with self._lock:
  84. self.active += 1
  85. self.peak_active = max(self.peak_active, self.active)
  86. def end(self, status: int, elapsed_s: float):
  87. with self._lock:
  88. self.active = max(0, self.active - 1)
  89. self.requests += 1
  90. self.status_counts[status] = self.status_counts.get(status, 0) + 1
  91. self.latencies.append(elapsed_s)
  92. self.recent_times.append(time.monotonic())
  93. if status == 429:
  94. self.rate_limited += 1
  95. def snapshot(self) -> dict:
  96. with self._lock:
  97. latencies = sorted(self.latencies) if self.latencies else []
  98. times = list(self.recent_times)
  99. qps = 0.0
  100. if len(times) >= 2:
  101. span = times[-1] - times[0]
  102. qps = round(len(times) / span, 2) if span > 0 else 0.0
  103. avg = round(sum(latencies) / len(latencies) * 1000, 1) if latencies else 0.0
  104. return {"requests": self.requests, "active": self.active,
  105. "peakConcurrency": self.peak_active,
  106. "qpsWindow": qps, "avgMs": avg,
  107. "maxMs": round(max(latencies) * 1000, 1) if latencies else 0.0,
  108. "statusCounts": dict(sorted(self.status_counts.items())),
  109. "rateLimited": self.rate_limited}
  110. def reset(self):
  111. with self._lock:
  112. self.requests = 0
  113. self.active = 0
  114. self.peak_active = 0
  115. self.status_counts.clear()
  116. self.latencies.clear()
  117. self.rate_limited = 0
  118. self.recent_times.clear()
  119. @dataclass
  120. class MockApiState:
  121. settings: MockApiSettings = field(default_factory=MockApiSettings)
  122. overrides: dict[str, list[dict]] = field(default_factory=dict)
  123. audit: list[dict] = field(default_factory=list)
  124. load_stats: MockApiLoadStats = field(default_factory=MockApiLoadStats)
  125. rate_hits: deque = field(default_factory=deque) # P2 限流滑动窗口
  126. def secrets(self) -> list[str]:
  127. s = self.settings
  128. return [v for v in (s.token, s.basic_password, s.api_key_value) if v]
  129. def _load_endpoints() -> dict[str, str]:
  130. with (MOCK_API_DIR / "endpoints.json").open("r", encoding="utf-8") as f:
  131. return json.load(f)
  132. def _load_rows(rel_path: str) -> list[dict]:
  133. p = MOCK_API_DIR / rel_path
  134. if not p.exists():
  135. raise HTTPException(status_code=404, detail=f"sample not found: {rel_path}")
  136. with p.open("r", encoding="utf-8") as f:
  137. return json.load(f)
  138. def _build_response(rows: list[dict], response_path: str) -> dict:
  139. parts = [p for p in response_path.split(".") if p]
  140. body: dict = {}
  141. node = body
  142. for part in parts[:-1]:
  143. node[part] = {}
  144. node = node[part]
  145. node[parts[-1]] = rows if parts else rows
  146. body.setdefault("code", 0)
  147. body.setdefault("message", "ok")
  148. return body
  149. def _check_auth(state: MockApiState, request: Request) -> None:
  150. s = state.settings
  151. mode = (s.auth_mode or "NONE").upper()
  152. if mode == "NONE":
  153. return
  154. authorization = request.headers.get("Authorization")
  155. if mode in ("TOKEN", "OAUTH2"):
  156. if authorization != f"Bearer {s.token}":
  157. raise HTTPException(status_code=401, detail="invalid or missing bearer token")
  158. elif mode == "BASIC":
  159. expected = "Basic " + base64.b64encode(f"{s.basic_user}:{s.basic_password}".encode()).decode()
  160. if authorization != expected:
  161. raise HTTPException(status_code=401, detail="invalid or missing basic credentials")
  162. elif mode == "APIKEY":
  163. if request.headers.get(s.api_key_header) != s.api_key_value:
  164. raise HTTPException(status_code=401, detail=f"invalid or missing api key header {s.api_key_header}")
  165. else:
  166. raise HTTPException(status_code=401, detail=f"unsupported auth mode on mock side: {mode}")
  167. def _rate_allowed(state: MockApiState, rps: float) -> bool:
  168. """滑动窗口限流:每秒最多 rps 个请求,超出返回 False(间歇 429,§19)。"""
  169. now = time.monotonic()
  170. while state.rate_hits and state.rate_hits[0] <= now - 1.0:
  171. state.rate_hits.popleft()
  172. if len(state.rate_hits) >= rps:
  173. return False
  174. state.rate_hits.append(now)
  175. return True
  176. def _active_faults(s: MockApiSettings) -> list[str]:
  177. """当前生效的异常注入清单(写入审计,便于人工核对「选择的异常」)。"""
  178. faults: list[str] = []
  179. if s.timeout_seconds > 0:
  180. faults.append(f"timeout:{s.timeout_seconds}s")
  181. if s.empty_response:
  182. faults.append("emptyResponse")
  183. if s.invalid_json:
  184. faults.append("invalidJson")
  185. if s.content_type:
  186. faults.append(f"contentType:{s.content_type}")
  187. if s.ignore_cursor:
  188. faults.append("ignoreCursor")
  189. if s.no_sort:
  190. faults.append("noSort")
  191. return faults
  192. def _audit(state: MockApiState, entry: dict) -> None:
  193. state.audit.append(entry)
  194. if len(state.audit) > state.settings.audit_limit:
  195. del state.audit[: len(state.audit) - state.settings.audit_limit]
  196. def create_app(state: MockApiState | None = None) -> FastAPI:
  197. state = state or MockApiState()
  198. app = FastAPI(title="Ai-DOP Integration Simulator - Mock API (API_PULL)", version="1.0.0")
  199. app.state.sim = state
  200. @app.get("/health")
  201. def health():
  202. return {"status": "UP", "authMode": state.settings.auth_mode}
  203. @app.get("/")
  204. def root():
  205. # Ai-DOP 数据源测连对 api_base_url 发起 GET 根路径,返回 200 使测连显示成功
  206. return {"status": "UP", "name": "Ai-DOP Integration Simulator Mock API",
  207. "authMode": state.settings.auth_mode}
  208. @app.get("/__endpoints")
  209. def list_endpoints():
  210. return {
  211. "authMode": state.settings.auth_mode,
  212. "responsePath": state.settings.response_path,
  213. "endpoints": _load_endpoints(),
  214. "overrides": sorted(state.overrides.keys()),
  215. }
  216. @app.get("/__control/settings")
  217. def get_settings():
  218. s = state.settings
  219. # 不回显任何凭据值
  220. return {
  221. "authMode": s.auth_mode, "delaySeconds": s.delay_seconds,
  222. "forceStatus": s.force_status, "responsePath": s.response_path,
  223. "apiKeyHeader": s.api_key_header,
  224. "timeoutSeconds": s.timeout_seconds, "emptyResponse": s.empty_response,
  225. "invalidJson": s.invalid_json, "contentType": s.content_type,
  226. "ignoreCursor": s.ignore_cursor, "noSort": s.no_sort,
  227. "loadMode": s.load_mode, "pageSize": s.page_size,
  228. "totalRowsLimit": s.total_rows_limit, "randomDelay": s.random_delay,
  229. "errorRate": s.error_rate, "rateLimit": s.rate_limit,
  230. }
  231. @app.post("/__control/settings")
  232. def update_settings(payload: dict):
  233. s = state.settings
  234. if payload.get("reset"):
  235. s.reset_faults()
  236. return get_settings()
  237. if "auth_mode" in payload:
  238. mode = str(payload["auth_mode"]).upper()
  239. if mode not in ("NONE", "TOKEN", "BASIC", "APIKEY", "OAUTH2"):
  240. raise HTTPException(status_code=400, detail=f"unknown auth_mode: {mode}")
  241. s.auth_mode = mode
  242. if "token" in payload and payload["token"]:
  243. s.token = str(payload["token"])
  244. if "basic_user" in payload and payload["basic_user"]:
  245. s.basic_user = str(payload["basic_user"])
  246. if "basic_password" in payload and payload["basic_password"]:
  247. s.basic_password = str(payload["basic_password"])
  248. if "api_key_header" in payload and payload["api_key_header"]:
  249. s.api_key_header = str(payload["api_key_header"])
  250. if "api_key_value" in payload and payload["api_key_value"]:
  251. s.api_key_value = str(payload["api_key_value"])
  252. if "delay_seconds" in payload:
  253. s.delay_seconds = max(0.0, float(payload["delay_seconds"]))
  254. if "force_status" in payload:
  255. s.force_status = int(payload["force_status"]) if payload["force_status"] else None
  256. if "response_path" in payload and payload["response_path"]:
  257. s.response_path = str(payload["response_path"])
  258. if "timeout_seconds" in payload:
  259. s.timeout_seconds = max(0.0, float(payload["timeout_seconds"]))
  260. if "empty_response" in payload:
  261. s.empty_response = bool(payload["empty_response"])
  262. if "invalid_json" in payload:
  263. s.invalid_json = bool(payload["invalid_json"])
  264. if "content_type" in payload:
  265. s.content_type = str(payload["content_type"]) if payload["content_type"] else None
  266. if "ignore_cursor" in payload:
  267. s.ignore_cursor = bool(payload["ignore_cursor"])
  268. if "no_sort" in payload:
  269. s.no_sort = bool(payload["no_sort"])
  270. if "load_mode" in payload:
  271. s.load_mode = bool(payload["load_mode"])
  272. if "page_size" in payload:
  273. s.page_size = max(0, int(payload["page_size"]))
  274. if "total_rows_limit" in payload:
  275. s.total_rows_limit = max(0, int(payload["total_rows_limit"]))
  276. if "random_delay" in payload:
  277. s.random_delay = max(0.0, float(payload["random_delay"]))
  278. if "error_rate" in payload:
  279. s.error_rate = min(1.0, max(0.0, float(payload["error_rate"])))
  280. if "rate_limit" in payload:
  281. s.rate_limit = max(0.0, float(payload["rate_limit"]))
  282. return get_settings()
  283. @app.post("/__control/overrides")
  284. def set_override(payload: dict):
  285. path = str(payload.get("path") or "")
  286. rows = payload.get("rows")
  287. if not path.startswith("/api/") or not isinstance(rows, list):
  288. raise HTTPException(status_code=400, detail="need {path:/api/..., rows:[...]}")
  289. state.overrides[path] = rows
  290. return {"path": path, "rowCount": len(rows)}
  291. @app.delete("/__control/overrides")
  292. def clear_overrides():
  293. state.overrides.clear()
  294. return {"cleared": True}
  295. @app.get("/__control/audit")
  296. def get_audit():
  297. return {"entries": state.audit[-50:]}
  298. @app.get("/__control/load-stats")
  299. def get_load_stats():
  300. """P2 压力模式统计快照(§19:请求量、并发、QPS、耗时和错误)。"""
  301. return {"loadMode": state.settings.load_mode, "stats": state.load_stats.snapshot()}
  302. @app.get("/api/{obj:path}")
  303. def get_object(
  304. obj: str,
  305. request: Request,
  306. cursor: str | None = Query(default=None, description="上批最后一行 bizKey(增量游标)"),
  307. ):
  308. started = time.time()
  309. path = request.url.path
  310. s = state.settings
  311. status_code = 200
  312. load_on = s.load_mode
  313. if load_on:
  314. state.load_stats.begin()
  315. try:
  316. if s.timeout_seconds > 0:
  317. # 超时模拟:睡眠超过 Ai-DOP 读取超时(由测试人员配置秒数)
  318. time.sleep(s.timeout_seconds)
  319. if s.empty_response:
  320. return Response(content="", media_type=s.content_type or "application/json")
  321. if s.invalid_json:
  322. return Response(content='{"data": {"list": [',
  323. media_type=s.content_type or "application/json")
  324. if s.delay_seconds > 0:
  325. time.sleep(s.delay_seconds)
  326. if s.random_delay > 0:
  327. # P2 随机延迟(固定/随机延迟测试类型)
  328. time.sleep(random.uniform(0, s.random_delay))
  329. if s.force_status:
  330. raise HTTPException(status_code=s.force_status,
  331. detail=f"forced error {s.force_status}")
  332. if load_on and s.rate_limit > 0 and not _rate_allowed(state, s.rate_limit):
  333. raise HTTPException(status_code=429,
  334. detail=f"mock load rate limit {s.rate_limit}/s exceeded")
  335. if load_on and s.error_rate > 0 and random.random() < s.error_rate:
  336. raise HTTPException(status_code=500, detail="mock load error injection")
  337. _check_auth(state, request)
  338. if path in state.overrides:
  339. rows = [dict(r) for r in state.overrides[path]]
  340. else:
  341. endpoints = _load_endpoints()
  342. if path not in endpoints:
  343. raise HTTPException(status_code=404,
  344. detail=f"no mock for {path}; add it to endpoints.json or set an override")
  345. rows = _load_rows(endpoints[path])
  346. if not s.no_sort:
  347. rows.sort(key=lambda r: str(r.get("bizKey", "")))
  348. if cursor and not s.ignore_cursor:
  349. rows = [r for r in rows if str(r.get("bizKey", "")) > cursor]
  350. if load_on and s.total_rows_limit > 0:
  351. rows = rows[:s.total_rows_limit]
  352. if load_on and s.page_size > 0:
  353. rows = rows[:s.page_size]
  354. body = _build_response(rows, s.response_path)
  355. if s.content_type:
  356. return Response(content=json.dumps(body, ensure_ascii=False),
  357. media_type=s.content_type)
  358. return body
  359. except HTTPException as ex:
  360. status_code = ex.status_code
  361. raise
  362. finally:
  363. elapsed = time.time() - started
  364. if load_on:
  365. state.load_stats.end(status_code, elapsed)
  366. _audit(state, {
  367. "time": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
  368. "path": path,
  369. "cursor": cursor,
  370. "authMode": state.settings.auth_mode,
  371. "forceStatus": state.settings.force_status,
  372. "faults": _active_faults(state.settings),
  373. "loadMode": load_on,
  374. "httpStatus": status_code,
  375. "elapsedMs": int(elapsed * 1000),
  376. "headers": redact_headers(dict(request.headers), state.secrets()),
  377. })
  378. return app