generate_openapi.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. #!/usr/bin/env python3
  2. """Generate the current Flask route inventory as an OpenAPI 3.1 contract."""
  3. from __future__ import annotations
  4. import argparse
  5. import ast
  6. import json
  7. import re
  8. from collections import defaultdict
  9. from pathlib import Path
  10. ROOT = Path(__file__).resolve().parents[1]
  11. OUTPUT = ROOT / "docs" / "architecture" / "OPENAPI.yaml"
  12. PREFIXES = {
  13. "meta_data": "/api/meta",
  14. "data_interface": "/api/interface",
  15. "data_rules": "/api/rules",
  16. "graph": "/api/graph",
  17. "system": "/api/system",
  18. "data_source": "/api/datasource",
  19. "data_development": "/api/development/v1",
  20. "data_flow": "/api/dataflow",
  21. "business_domain": "/api/bd",
  22. "data_factory": "/api/datafactory",
  23. "data_service": "/api/dataservice",
  24. "knowledge_base": "/api/knowledge",
  25. }
  26. SHORTHAND_METHODS = {"get", "post", "put", "patch", "delete"}
  27. RESPONSE_FIELDS = {
  28. ("data_development", "summary"): [
  29. "scope",
  30. "metrics",
  31. ],
  32. ("data_development", "details"): [
  33. "metric",
  34. "state",
  35. "records",
  36. "page",
  37. "page_size",
  38. "total",
  39. ],
  40. ("knowledge_base", "ask"): [
  41. "query_id",
  42. "mode",
  43. "answer",
  44. "answer_status",
  45. "degraded_components",
  46. "citations",
  47. "evidence",
  48. "freshness_status",
  49. ],
  50. }
  51. def quoted(value: str) -> str:
  52. return json.dumps(value, ensure_ascii=False)
  53. def route_path(raw_path: str) -> tuple[str, list[dict[str, str]]]:
  54. parameters: list[dict[str, str]] = []
  55. def replace(match: re.Match[str]) -> str:
  56. converter = match.group(1) or "string"
  57. name = match.group(2)
  58. schema_type = "integer" if converter in {"int", "float"} else "string"
  59. parameters.append({"name": name, "type": schema_type})
  60. return "{" + name + "}"
  61. return re.sub(r"<(?:([a-zA-Z_]+):)?([a-zA-Z_][a-zA-Z0-9_]*)>", replace, raw_path), parameters
  62. def extract_routes() -> list[dict[str, object]]:
  63. routes: list[dict[str, object]] = []
  64. for module, prefix in PREFIXES.items():
  65. for route_file in sorted((ROOT / "app" / "api" / module).glob("*.py")):
  66. if route_file.name == "__init__.py":
  67. continue
  68. tree = ast.parse(route_file.read_text(encoding="utf-8"))
  69. for node in tree.body:
  70. if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
  71. continue
  72. for decorator in node.decorator_list:
  73. if not isinstance(decorator, ast.Call):
  74. continue
  75. function = decorator.func
  76. if not (
  77. isinstance(function, ast.Attribute)
  78. and isinstance(function.value, ast.Name)
  79. and function.value.id == "bp"
  80. and decorator.args
  81. ):
  82. continue
  83. if function.attr == "route":
  84. methods = ["GET"]
  85. for keyword in decorator.keywords:
  86. if keyword.arg == "methods":
  87. methods = ast.literal_eval(keyword.value)
  88. elif function.attr in SHORTHAND_METHODS:
  89. methods = [function.attr.upper()]
  90. else:
  91. continue
  92. raw_path = ast.literal_eval(decorator.args[0])
  93. path, parameters = route_path(prefix + raw_path)
  94. summary = (ast.get_docstring(node) or node.name.replace("_", " ")).splitlines()[0]
  95. for method in methods:
  96. routes.append(
  97. {
  98. "path": path,
  99. "method": method.lower(),
  100. "tag": module,
  101. "operation_id": f"{module}_{node.name}_{method.lower()}",
  102. "summary": summary,
  103. "parameters": parameters,
  104. "source": str(route_file.relative_to(ROOT)),
  105. }
  106. )
  107. return sorted(routes, key=lambda item: (str(item["path"]), str(item["method"])))
  108. def render(routes: list[dict[str, object]]) -> str:
  109. by_path: dict[str, list[dict[str, object]]] = defaultdict(list)
  110. for route in routes:
  111. by_path[str(route["path"])].append(route)
  112. lines = [
  113. "openapi: 3.1.0",
  114. "info:",
  115. ' title: "DataOps Platform API(当前代码基线)"',
  116. ' version: "2026-07-16"',
  117. ' description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"',
  118. f"x-route-count: {len(routes)}",
  119. "servers:",
  120. ' - url: "http://localhost:15500"',
  121. ' description: "全本地隔离测试后端"',
  122. "tags:",
  123. ]
  124. for tag in PREFIXES:
  125. lines.extend([f" - name: {tag}", f" description: {quoted(PREFIXES[tag])}"])
  126. lines.append("paths:")
  127. for path, operations in sorted(by_path.items()):
  128. lines.append(f" {quoted(path)}:")
  129. for operation in operations:
  130. lines.extend(
  131. [
  132. f" {operation['method']}:",
  133. f" tags: [{operation['tag']}]",
  134. f" operationId: {operation['operation_id']}",
  135. f" summary: {quoted(str(operation['summary']))}",
  136. f" x-source: {quoted(str(operation['source']))}",
  137. ]
  138. )
  139. parameters = operation["parameters"]
  140. if parameters:
  141. lines.append(" parameters:")
  142. for parameter in parameters:
  143. lines.extend(
  144. [
  145. f" - name: {parameter['name']}",
  146. " in: path",
  147. " required: true",
  148. " schema:",
  149. f" type: {parameter['type']}",
  150. ]
  151. )
  152. if operation["method"] in {"post", "put", "patch"}:
  153. lines.extend(
  154. [
  155. " requestBody:",
  156. " required: false",
  157. " content:",
  158. " application/json:",
  159. " schema:",
  160. " type: object",
  161. " additionalProperties: true",
  162. ]
  163. )
  164. response_fields = RESPONSE_FIELDS.get(
  165. (str(operation["tag"]), str(operation["operation_id"]).split("_")[-2])
  166. )
  167. if response_fields:
  168. lines.append(
  169. " x-response-fields: ["
  170. + ", ".join(response_fields)
  171. + "]"
  172. )
  173. lines.extend(
  174. [
  175. " responses:",
  176. ' "200":',
  177. ' description: "请求已由当前实现处理"',
  178. " content:",
  179. " application/json:",
  180. " schema:",
  181. " $ref: '#/components/schemas/ApiEnvelope'",
  182. " default:",
  183. ' description: "错误响应"',
  184. " content:",
  185. " application/json:",
  186. " schema:",
  187. " $ref: '#/components/schemas/ApiEnvelope'",
  188. ]
  189. )
  190. lines.extend(
  191. [
  192. "components:",
  193. " schemas:",
  194. " ApiEnvelope:",
  195. " type: object",
  196. " additionalProperties: true",
  197. " properties:",
  198. " success:",
  199. " type: boolean",
  200. " code:",
  201. " type: integer",
  202. " message:",
  203. " type: string",
  204. " data: {}",
  205. " timestamp:",
  206. " type: integer",
  207. " securitySchemes:",
  208. " bearerAuth:",
  209. " type: http",
  210. " scheme: bearer",
  211. " bearerFormat: JWT",
  212. " description: \"下一阶段统一认证方案;当前路由尚未全部接入。\"",
  213. ]
  214. )
  215. return "\n".join(lines) + "\n"
  216. def main(output: Path = OUTPUT) -> None:
  217. routes = extract_routes()
  218. if not routes:
  219. raise SystemExit("No Flask routes found")
  220. output.parent.mkdir(parents=True, exist_ok=True)
  221. output.write_text(render(routes), encoding="utf-8")
  222. try:
  223. label = output.relative_to(ROOT)
  224. except ValueError:
  225. label = output
  226. print(f"Generated {label} with {len(routes)} operations")
  227. if __name__ == "__main__":
  228. parser = argparse.ArgumentParser(description=__doc__)
  229. parser.add_argument("--output", type=Path, default=OUTPUT)
  230. args = parser.parse_args()
  231. main(args.output)