mock_api.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  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 sys
  15. import time
  16. from dataclasses import dataclass, field
  17. from pathlib import Path
  18. _SIM_ROOT = Path(__file__).resolve().parents[1]
  19. if str(_SIM_ROOT) not in sys.path:
  20. sys.path.insert(0, str(_SIM_ROOT))
  21. from fastapi import FastAPI, HTTPException, Query, Request, Response # noqa: E402
  22. from catalog.sample_adapters import MOCK_API_DIR # noqa: E402
  23. from clients.redaction import redact_headers # noqa: E402
  24. @dataclass
  25. class MockApiSettings:
  26. auth_mode: str = "TOKEN" # NONE | TOKEN | BASIC | APIKEY
  27. token: str = "sim-mock-token"
  28. basic_user: str = "sim"
  29. basic_password: str = "sim-basic-password"
  30. api_key_header: str = "X-Api-Key"
  31. api_key_value: str = "sim-api-key"
  32. delay_seconds: float = 0.0
  33. force_status: int | None = None # 401/403/404/429/500/503 等
  34. response_path: str = "data.list"
  35. audit_limit: int = 200
  36. # ---- 第二阶段 N2 异常模式(内存态,恢复默认即清除)----
  37. timeout_seconds: float = 0.0 # >0 时按秒数睡眠,模拟超过 Ai-DOP 超时
  38. empty_response: bool = False # 返回 200 空 body
  39. invalid_json: bool = False # 返回非法 JSON 文本
  40. content_type: str | None = None # 覆盖响应 Content-Type(如 text/html)
  41. ignore_cursor: bool = False # 无视 cursor 返回全部行(cursor 不推进)
  42. no_sort: bool = False # 不按 bizKey 排序(cursor 乱序)
  43. def reset_faults(self) -> None:
  44. """恢复默认:清空全部异常注入设置(鉴权/凭据类保留)。"""
  45. self.auth_mode = "TOKEN"
  46. self.delay_seconds = 0.0
  47. self.force_status = None
  48. self.response_path = "data.list"
  49. self.timeout_seconds = 0.0
  50. self.empty_response = False
  51. self.invalid_json = False
  52. self.content_type = None
  53. self.ignore_cursor = False
  54. self.no_sort = False
  55. @dataclass
  56. class MockApiState:
  57. settings: MockApiSettings = field(default_factory=MockApiSettings)
  58. overrides: dict[str, list[dict]] = field(default_factory=dict)
  59. audit: list[dict] = field(default_factory=list)
  60. def secrets(self) -> list[str]:
  61. s = self.settings
  62. return [v for v in (s.token, s.basic_password, s.api_key_value) if v]
  63. def _load_endpoints() -> dict[str, str]:
  64. with (MOCK_API_DIR / "endpoints.json").open("r", encoding="utf-8") as f:
  65. return json.load(f)
  66. def _load_rows(rel_path: str) -> list[dict]:
  67. p = MOCK_API_DIR / rel_path
  68. if not p.exists():
  69. raise HTTPException(status_code=404, detail=f"sample not found: {rel_path}")
  70. with p.open("r", encoding="utf-8") as f:
  71. return json.load(f)
  72. def _build_response(rows: list[dict], response_path: str) -> dict:
  73. parts = [p for p in response_path.split(".") if p]
  74. body: dict = {}
  75. node = body
  76. for part in parts[:-1]:
  77. node[part] = {}
  78. node = node[part]
  79. node[parts[-1]] = rows if parts else rows
  80. body.setdefault("code", 0)
  81. body.setdefault("message", "ok")
  82. return body
  83. def _check_auth(state: MockApiState, request: Request) -> None:
  84. s = state.settings
  85. mode = (s.auth_mode or "NONE").upper()
  86. if mode == "NONE":
  87. return
  88. authorization = request.headers.get("Authorization")
  89. if mode in ("TOKEN", "OAUTH2"):
  90. if authorization != f"Bearer {s.token}":
  91. raise HTTPException(status_code=401, detail="invalid or missing bearer token")
  92. elif mode == "BASIC":
  93. expected = "Basic " + base64.b64encode(f"{s.basic_user}:{s.basic_password}".encode()).decode()
  94. if authorization != expected:
  95. raise HTTPException(status_code=401, detail="invalid or missing basic credentials")
  96. elif mode == "APIKEY":
  97. if request.headers.get(s.api_key_header) != s.api_key_value:
  98. raise HTTPException(status_code=401, detail=f"invalid or missing api key header {s.api_key_header}")
  99. else:
  100. raise HTTPException(status_code=401, detail=f"unsupported auth mode on mock side: {mode}")
  101. def _active_faults(s: MockApiSettings) -> list[str]:
  102. """当前生效的异常注入清单(写入审计,便于人工核对「选择的异常」)。"""
  103. faults: list[str] = []
  104. if s.timeout_seconds > 0:
  105. faults.append(f"timeout:{s.timeout_seconds}s")
  106. if s.empty_response:
  107. faults.append("emptyResponse")
  108. if s.invalid_json:
  109. faults.append("invalidJson")
  110. if s.content_type:
  111. faults.append(f"contentType:{s.content_type}")
  112. if s.ignore_cursor:
  113. faults.append("ignoreCursor")
  114. if s.no_sort:
  115. faults.append("noSort")
  116. return faults
  117. def _audit(state: MockApiState, entry: dict) -> None:
  118. state.audit.append(entry)
  119. if len(state.audit) > state.settings.audit_limit:
  120. del state.audit[: len(state.audit) - state.settings.audit_limit]
  121. def create_app(state: MockApiState | None = None) -> FastAPI:
  122. state = state or MockApiState()
  123. app = FastAPI(title="Ai-DOP Integration Simulator - Mock API (API_PULL)", version="1.0.0")
  124. app.state.sim = state
  125. @app.get("/health")
  126. def health():
  127. return {"status": "UP", "authMode": state.settings.auth_mode}
  128. @app.get("/")
  129. def root():
  130. # Ai-DOP 数据源测连对 api_base_url 发起 GET 根路径,返回 200 使测连显示成功
  131. return {"status": "UP", "name": "Ai-DOP Integration Simulator Mock API",
  132. "authMode": state.settings.auth_mode}
  133. @app.get("/__endpoints")
  134. def list_endpoints():
  135. return {
  136. "authMode": state.settings.auth_mode,
  137. "responsePath": state.settings.response_path,
  138. "endpoints": _load_endpoints(),
  139. "overrides": sorted(state.overrides.keys()),
  140. }
  141. @app.get("/__control/settings")
  142. def get_settings():
  143. s = state.settings
  144. # 不回显任何凭据值
  145. return {
  146. "authMode": s.auth_mode, "delaySeconds": s.delay_seconds,
  147. "forceStatus": s.force_status, "responsePath": s.response_path,
  148. "apiKeyHeader": s.api_key_header,
  149. "timeoutSeconds": s.timeout_seconds, "emptyResponse": s.empty_response,
  150. "invalidJson": s.invalid_json, "contentType": s.content_type,
  151. "ignoreCursor": s.ignore_cursor, "noSort": s.no_sort,
  152. }
  153. @app.post("/__control/settings")
  154. def update_settings(payload: dict):
  155. s = state.settings
  156. if payload.get("reset"):
  157. s.reset_faults()
  158. return get_settings()
  159. if "auth_mode" in payload:
  160. mode = str(payload["auth_mode"]).upper()
  161. if mode not in ("NONE", "TOKEN", "BASIC", "APIKEY", "OAUTH2"):
  162. raise HTTPException(status_code=400, detail=f"unknown auth_mode: {mode}")
  163. s.auth_mode = mode
  164. if "token" in payload and payload["token"]:
  165. s.token = str(payload["token"])
  166. if "basic_user" in payload and payload["basic_user"]:
  167. s.basic_user = str(payload["basic_user"])
  168. if "basic_password" in payload and payload["basic_password"]:
  169. s.basic_password = str(payload["basic_password"])
  170. if "api_key_header" in payload and payload["api_key_header"]:
  171. s.api_key_header = str(payload["api_key_header"])
  172. if "api_key_value" in payload and payload["api_key_value"]:
  173. s.api_key_value = str(payload["api_key_value"])
  174. if "delay_seconds" in payload:
  175. s.delay_seconds = max(0.0, float(payload["delay_seconds"]))
  176. if "force_status" in payload:
  177. s.force_status = int(payload["force_status"]) if payload["force_status"] else None
  178. if "response_path" in payload and payload["response_path"]:
  179. s.response_path = str(payload["response_path"])
  180. if "timeout_seconds" in payload:
  181. s.timeout_seconds = max(0.0, float(payload["timeout_seconds"]))
  182. if "empty_response" in payload:
  183. s.empty_response = bool(payload["empty_response"])
  184. if "invalid_json" in payload:
  185. s.invalid_json = bool(payload["invalid_json"])
  186. if "content_type" in payload:
  187. s.content_type = str(payload["content_type"]) if payload["content_type"] else None
  188. if "ignore_cursor" in payload:
  189. s.ignore_cursor = bool(payload["ignore_cursor"])
  190. if "no_sort" in payload:
  191. s.no_sort = bool(payload["no_sort"])
  192. return get_settings()
  193. @app.post("/__control/overrides")
  194. def set_override(payload: dict):
  195. path = str(payload.get("path") or "")
  196. rows = payload.get("rows")
  197. if not path.startswith("/api/") or not isinstance(rows, list):
  198. raise HTTPException(status_code=400, detail="need {path:/api/..., rows:[...]}")
  199. state.overrides[path] = rows
  200. return {"path": path, "rowCount": len(rows)}
  201. @app.delete("/__control/overrides")
  202. def clear_overrides():
  203. state.overrides.clear()
  204. return {"cleared": True}
  205. @app.get("/__control/audit")
  206. def get_audit():
  207. return {"entries": state.audit[-50:]}
  208. @app.get("/api/{obj:path}")
  209. def get_object(
  210. obj: str,
  211. request: Request,
  212. cursor: str | None = Query(default=None, description="上批最后一行 bizKey(增量游标)"),
  213. ):
  214. started = time.time()
  215. path = request.url.path
  216. s = state.settings
  217. try:
  218. if s.timeout_seconds > 0:
  219. # 超时模拟:睡眠超过 Ai-DOP 读取超时(由测试人员配置秒数)
  220. time.sleep(s.timeout_seconds)
  221. if s.empty_response:
  222. return Response(content="", media_type=s.content_type or "application/json")
  223. if s.invalid_json:
  224. return Response(content='{"data": {"list": [',
  225. media_type=s.content_type or "application/json")
  226. if s.delay_seconds > 0:
  227. time.sleep(s.delay_seconds)
  228. if s.force_status:
  229. raise HTTPException(status_code=s.force_status,
  230. detail=f"forced error {s.force_status}")
  231. _check_auth(state, request)
  232. if path in state.overrides:
  233. rows = [dict(r) for r in state.overrides[path]]
  234. else:
  235. endpoints = _load_endpoints()
  236. if path not in endpoints:
  237. raise HTTPException(status_code=404,
  238. detail=f"no mock for {path}; add it to endpoints.json or set an override")
  239. rows = _load_rows(endpoints[path])
  240. if not s.no_sort:
  241. rows.sort(key=lambda r: str(r.get("bizKey", "")))
  242. if cursor and not s.ignore_cursor:
  243. rows = [r for r in rows if str(r.get("bizKey", "")) > cursor]
  244. body = _build_response(rows, s.response_path)
  245. if s.content_type:
  246. return Response(content=json.dumps(body, ensure_ascii=False),
  247. media_type=s.content_type)
  248. return body
  249. finally:
  250. _audit(state, {
  251. "time": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
  252. "path": path,
  253. "cursor": cursor,
  254. "authMode": state.settings.auth_mode,
  255. "forceStatus": state.settings.force_status,
  256. "faults": _active_faults(state.settings),
  257. "elapsedMs": int((time.time() - started) * 1000),
  258. "headers": redact_headers(dict(request.headers), state.secrets()),
  259. })
  260. return app