parser_agent.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. """
  2. 解析Agent - 负责解析 OpenAPI/Swagger 文档
  3. 这是"智能体层"里第一个不需要 LLM 的 Agent——
  4. 它的活是确定性的(读文档、提取结构),用普通 Python 类就够了。
  5. 除了提取接口清单,这里还负责两件对后续流程很关键的确定性工作:
  6. 1. 展开本地 $ref 引用(#/components/schemas/...),让生成Agent和验证Agent
  7. 能直接看到真实字段,而不是一个干巴巴的引用字符串。
  8. 2. 根据文档的 responses 推断"期望状态码"和"期望响应 schema",
  9. 不把状态码的判断交给 LLM。
  10. """
  11. import yaml
  12. import json
  13. import requests
  14. from pathlib import Path
  15. class ParserAgent:
  16. """解析 OpenAPI/Swagger 文档,提取接口清单"""
  17. # OpenAPI 里常见的 HTTP 方法
  18. HTTP_METHODS = ["get", "post", "put", "delete", "patch"]
  19. def parse_file(self, file_path):
  20. """解析文档文件,返回接口列表
  21. Args:
  22. file_path: OpenAPI 文档路径(支持 .yaml / .yml / .json)
  23. Returns:
  24. 接口列表,每个接口是一个字典:
  25. [
  26. {
  27. "path": "/users",
  28. "method": "GET",
  29. "parameters": [...], # 参数($ref 已展开)
  30. "request_body": {...}, # 请求体 schema($ref 已展开,无则 None)
  31. "request_content_type": "...",# 请求体媒体类型,如 application/json
  32. "responses": {...}, # 各状态码的响应(schema 已展开)
  33. "response_schema": {...}, # 主成功响应的 schema(无则 None)
  34. "security": [...], # 安全要求(如 Bearer 认证)
  35. },
  36. ...
  37. ]
  38. """
  39. text = Path(file_path).read_text(encoding="utf-8")
  40. return self.parse_text(text)
  41. def parse_url(self, url):
  42. """从 URL 抓取 OpenAPI 文档并解析
  43. Args:
  44. url: OpenAPI 文档的网址(如 https://httpbin.org/spec.json)
  45. Returns:
  46. 接口列表
  47. """
  48. try:
  49. # 用 requests 抓取网络上的文档内容
  50. resp = requests.get(url, timeout=30)
  51. # 非 200 状态码会抛异常
  52. resp.raise_for_status()
  53. except requests.RequestException as e:
  54. # 抓取失败(如 503 服务不可用、网络超时),不崩溃,返回空列表
  55. print(f"[警告] 从 URL 抓取文档失败:{e}")
  56. return []
  57. # 抓到的内容和本地文件一样,复用 parse_text 解析
  58. return self.parse_text(resp.text)
  59. def parse_text(self, text):
  60. """解析文档文本,自动判断 JSON 还是 YAML
  61. Args:
  62. text: OpenAPI 文档内容(字符串,前端直接传这个)
  63. Returns:
  64. 接口列表
  65. """
  66. text = text.strip()
  67. # 空输入直接返回空列表,避免 yaml.safe_load("") 返回 None 导致后续崩溃
  68. if not text:
  69. return []
  70. # 先尝试按 JSON 解析,失败则按 YAML 解析
  71. # (JSON 也是合法的 YAML,但反过来不成立,所以先试 JSON)
  72. try:
  73. openapi_dict = json.loads(text)
  74. except json.JSONDecodeError:
  75. openapi_dict = yaml.safe_load(text)
  76. return self.extract_endpoints(openapi_dict)
  77. # ============ $ref 展开 ============
  78. def _resolve_ref(self, ref, openapi_dict, _stack):
  79. """解析单个本地 $ref 引用(如 #/components/schemas/User)
  80. 沿 #/components/schemas/User 逐级取值,再递归展开目标里的嵌套引用。
  81. 用 _stack 记录当前解析链,避免循环引用(A → B → A)导致无限递归。
  82. Args:
  83. ref: 引用字符串
  84. openapi_dict: 完整文档字典
  85. _stack: 解析链集合(用于循环检测)
  86. Returns:
  87. 展开后的 schema;引用指向不存在的目标或成环时,原样返回引用字符串
  88. """
  89. if not isinstance(ref, str) or not ref.startswith("#/"):
  90. return ref
  91. if ref in _stack:
  92. # 循环引用:保留引用原样,避免死循环
  93. return ref
  94. parts = ref.lstrip("#/").split("/")
  95. node = openapi_dict
  96. for part in parts:
  97. node = node.get(part) if isinstance(node, dict) else None
  98. if node is None:
  99. # 引用指向不存在的目标,原样返回
  100. return ref
  101. _stack.add(ref)
  102. resolved = self._expand(node, openapi_dict, _stack)
  103. _stack.discard(ref)
  104. return resolved
  105. def _expand(self, node, openapi_dict, _stack=None):
  106. """递归展开 node 里所有嵌套的 $ref 引用,返回全新结构(不改原文档)
  107. Args:
  108. node: 任意 JSON/YAML 结构
  109. openapi_dict: 完整文档字典
  110. _stack: 解析链集合
  111. Returns:
  112. 展开后的全新结构
  113. """
  114. if _stack is None:
  115. _stack = set()
  116. if isinstance(node, dict):
  117. if "$ref" in node:
  118. resolved = self._resolve_ref(node["$ref"], openapi_dict, _stack)
  119. # OpenAPI 3.1 允许 $ref 与其它字段共存,兄弟字段覆盖引用结果
  120. siblings = {
  121. k: self._expand(v, openapi_dict, _stack)
  122. for k, v in node.items() if k != "$ref"
  123. }
  124. if isinstance(resolved, dict) and siblings:
  125. merged = dict(resolved)
  126. merged.update(siblings)
  127. return merged
  128. return resolved
  129. return {
  130. k: self._expand(v, openapi_dict, _stack)
  131. for k, v in node.items()
  132. }
  133. if isinstance(node, list):
  134. return [self._expand(v, openapi_dict, _stack) for v in node]
  135. return node
  136. # ============ 提取接口 ============
  137. def extract_endpoints(self, openapi_dict):
  138. """从 OpenAPI 结构里提取所有接口(含 $ref 展开)
  139. Args:
  140. openapi_dict: 解析后的 OpenAPI 字典
  141. Returns:
  142. 接口列表
  143. """
  144. endpoints = []
  145. # 类型检查:解析结果可能是 None 或非 dict(如 yaml 解析出字符串),直接返回空
  146. if not isinstance(openapi_dict, dict):
  147. return endpoints
  148. # paths 可能缺失或为 None,统一兜底为空字典
  149. paths = openapi_dict.get("paths") or {}
  150. for path, path_item in paths.items():
  151. # path_item 也可能不是 dict(不规范文档),跳过
  152. if not isinstance(path_item, dict):
  153. continue
  154. # 每个 path 下可能有多个方法(get、post 等)
  155. for method in self.HTTP_METHODS:
  156. if method in path_item:
  157. operation = path_item[method]
  158. # 请求体:解析出 schema 和媒体类型(application/json / multipart/form-data)
  159. request_body, content_type = self._extract_request_body(
  160. operation, openapi_dict
  161. )
  162. # 参数和响应都要展开 $ref,方便后续生成/校验
  163. parameters = self._resolve_parameters(
  164. operation.get("parameters", []), openapi_dict
  165. )
  166. responses = self._resolve_responses(
  167. operation.get("responses", {}), openapi_dict
  168. )
  169. endpoints.append({
  170. "path": path,
  171. "method": method.upper(),
  172. "summary": operation.get("summary", ""),
  173. "parameters": parameters,
  174. "request_body": request_body,
  175. "request_content_type": content_type,
  176. "responses": responses,
  177. "response_schema": self._primary_response_schema(responses),
  178. "security": operation.get("security"),
  179. })
  180. return endpoints
  181. def _extract_request_body(self, operation, openapi_dict):
  182. """提取请求体的 schema 与媒体类型
  183. Args:
  184. operation: 单个方法对应的 operation 字典
  185. openapi_dict: 完整文档字典
  186. Returns:
  187. (schema, content_type) 二元组;没有请求体时返回 (None, None)
  188. """
  189. request_body = operation.get("requestBody")
  190. if not isinstance(request_body, dict):
  191. return None, None
  192. # requestBody 本身也可能是个 $ref
  193. if "$ref" in request_body:
  194. request_body = self._expand(request_body, openapi_dict)
  195. content = request_body.get("content") or {}
  196. # 优先 JSON,其次 multipart/form-data,最后取第一个可用媒体类型
  197. for media_type in ("application/json", "multipart/form-data"):
  198. media = content.get(media_type)
  199. if isinstance(media, dict) and "schema" in media:
  200. return self._expand(media["schema"], openapi_dict), media_type
  201. for media_type, media in content.items():
  202. if isinstance(media, dict) and "schema" in media:
  203. return self._expand(media["schema"], openapi_dict), media_type
  204. return None, None
  205. def _resolve_parameters(self, parameters, openapi_dict):
  206. """展开参数列表里的 $ref(参数本身和参数里的 schema)
  207. Args:
  208. parameters: operation 的 parameters 列表
  209. openapi_dict: 完整文档字典
  210. Returns:
  211. 展开后的参数列表
  212. """
  213. resolved = []
  214. for param in parameters or []:
  215. if not isinstance(param, dict):
  216. continue
  217. # 参数本身可能是 $ref(#/components/parameters/...)
  218. if "$ref" in param:
  219. param = self._expand(param, openapi_dict)
  220. param = dict(param)
  221. if "schema" in param:
  222. param["schema"] = self._expand(param["schema"], openapi_dict)
  223. resolved.append(param)
  224. return resolved
  225. def _resolve_responses(self, responses, openapi_dict):
  226. """展开 responses 里每个响应体 schema 的 $ref
  227. Args:
  228. responses: operation 的 responses 字典
  229. openapi_dict: 完整文档字典
  230. Returns:
  231. 展开后的 responses 字典
  232. """
  233. resolved = {}
  234. for code, resp in responses.items():
  235. if not isinstance(resp, dict):
  236. resolved[code] = resp
  237. continue
  238. content = resp.get("content") or {}
  239. new_content = {}
  240. for media_type, media in content.items():
  241. if isinstance(media, dict) and "schema" in media:
  242. media = dict(media)
  243. media["schema"] = self._expand(media["schema"], openapi_dict)
  244. new_content[media_type] = media
  245. new_resp = dict(resp)
  246. new_resp["content"] = new_content
  247. resolved[code] = new_resp
  248. return resolved
  249. def _primary_response_schema(self, responses):
  250. """取第一个 2xx 成功响应的 schema(作为正常用例的校验依据)
  251. Args:
  252. responses: 已展开的 responses 字典
  253. Returns:
  254. 成功响应的 schema,没有则 None
  255. """
  256. for code in responses:
  257. if str(code).startswith("2"):
  258. schema = self._extract_response_schema(responses, code)
  259. if schema is not None:
  260. return schema
  261. return None
  262. @staticmethod
  263. def _extract_response_schema(responses, status):
  264. """从 responses 里按状态码取响应体 schema
  265. Args:
  266. responses: 已展开的 responses 字典
  267. status: 状态码(int 或 str)
  268. Returns:
  269. 对应状态码的响应 schema,没有则 None
  270. """
  271. resp = responses.get(str(status)) or responses.get(status)
  272. if not isinstance(resp, dict):
  273. return None
  274. content = resp.get("content") or {}
  275. # 优先 JSON,兼容 */* 和任意媒体类型
  276. for media_type in ("application/json", "*/*"):
  277. media = content.get(media_type)
  278. if isinstance(media, dict) and "schema" in media:
  279. return media["schema"]
  280. for media in content.values():
  281. if isinstance(media, dict) and "schema" in media:
  282. return media["schema"]
  283. return None
  284. # ============ 结构判断辅助 ============
  285. # 这些是"这个接口有没有可测试的输入"之类的确定性判断,
  286. # 生成Agent 用它们决定要不要生成 boundary/error 用例。
  287. @staticmethod
  288. def _query_params(endpoint):
  289. return [
  290. p for p in endpoint.get("parameters", [])
  291. if isinstance(p, dict) and p.get("in") == "query"
  292. ]
  293. @staticmethod
  294. def _path_params(endpoint):
  295. return [
  296. p for p in endpoint.get("parameters", [])
  297. if isinstance(p, dict) and p.get("in") == "path"
  298. ]
  299. @staticmethod
  300. def _required_body_fields(endpoint):
  301. body = endpoint.get("request_body")
  302. if isinstance(body, dict) and isinstance(body.get("required"), list):
  303. return body["required"]
  304. return []
  305. @staticmethod
  306. def has_validation_input(endpoint):
  307. """是否有必填的 query 参数或必填请求体字段
  308. 这类接口缺少输入时会触发参数/请求体校验失败(通常 422)。
  309. """
  310. if any(p.get("required") for p in ParserAgent._query_params(endpoint)):
  311. return True
  312. return bool(ParserAgent._required_body_fields(endpoint))
  313. @staticmethod
  314. def has_required_path_param(endpoint):
  315. """是否有必填的路径参数
  316. 路径参数缺失会导致路由不匹配(通常 404),和校验失败(422)要区分开。
  317. """
  318. return any(p.get("required") for p in ParserAgent._path_params(endpoint))
  319. @staticmethod
  320. def has_testable_inputs(endpoint):
  321. """是否有可测试的输入(query 参数或请求体),决定是否生成 boundary 用例"""
  322. return bool(ParserAgent._query_params(endpoint)) or endpoint.get("request_body") is not None
  323. # ============ 期望状态码 / 期望 schema ============
  324. def get_expected_status(self, endpoint, case_type="normal"):
  325. """根据接口定义和用例类型,确定期望的状态码
  326. 这一步是确定性的,不交给 LLM,避免 LLM 随意生成错误的状态码。
  327. Args:
  328. endpoint: 单个接口字典
  329. case_type: 用例类型,normal / boundary / error
  330. Returns:
  331. 期望的状态码(int)
  332. """
  333. responses = endpoint.get("responses", {})
  334. if case_type in ("normal", "boundary"):
  335. # 正常/边界场景:优先 2xx 成功状态码
  336. for code in responses:
  337. if str(code).startswith("2"):
  338. return int(code)
  339. elif case_type == "error":
  340. # 异常场景要区分两类:
  341. # - 有必填 query/请求体 → 缺字段/传错类型 → 校验失败 4xx(通常 422)
  342. # - 只有必填路径参数 → 缺路径参数 → 路由不匹配 → 404
  343. if self.has_validation_input(endpoint):
  344. for code in responses:
  345. if str(code).startswith("4"):
  346. return int(code)
  347. # 文档没声明 4xx 时,按 FastAPI 校验错误约定默认 422
  348. return 422
  349. if self.has_required_path_param(endpoint):
  350. return 404
  351. for code in responses:
  352. if str(code).startswith("4"):
  353. return int(code)
  354. return 400
  355. # 兜底:返回第一个声明的状态码
  356. for code in responses:
  357. return int(code)
  358. # 文档里什么都没声明,默认 200
  359. return 200
  360. def get_response_schema(self, endpoint, status):
  361. """按状态码取接口的响应 schema($ref 已展开)
  362. Args:
  363. endpoint: 单个接口字典
  364. status: 状态码(int 或 str)
  365. Returns:
  366. 对应状态码的响应 schema,没有则 None
  367. """
  368. return self._extract_response_schema(endpoint.get("responses", {}), status)