generate_openapi.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  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 ast
  5. import argparse
  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. "graph": "/api/graph",
  16. "system": "/api/system",
  17. "data_source": "/api/datasource",
  18. "data_flow": "/api/dataflow",
  19. "business_domain": "/api/bd",
  20. "data_factory": "/api/datafactory",
  21. "data_service": "/api/dataservice",
  22. }
  23. def quoted(value: str) -> str:
  24. return json.dumps(value, ensure_ascii=False)
  25. def route_path(raw_path: str) -> tuple[str, list[dict[str, str]]]:
  26. parameters: list[dict[str, str]] = []
  27. def replace(match: re.Match[str]) -> str:
  28. converter = match.group(1) or "string"
  29. name = match.group(2)
  30. schema_type = "integer" if converter in {"int", "float"} else "string"
  31. parameters.append({"name": name, "type": schema_type})
  32. return "{" + name + "}"
  33. return re.sub(r"<(?:([a-zA-Z_]+):)?([a-zA-Z_][a-zA-Z0-9_]*)>", replace, raw_path), parameters
  34. def extract_routes() -> list[dict[str, object]]:
  35. routes: list[dict[str, object]] = []
  36. for module, prefix in PREFIXES.items():
  37. for route_file in sorted((ROOT / "app" / "api" / module).glob("*.py")):
  38. if route_file.name == "__init__.py":
  39. continue
  40. tree = ast.parse(route_file.read_text(encoding="utf-8"))
  41. for node in tree.body:
  42. if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
  43. continue
  44. for decorator in node.decorator_list:
  45. if not isinstance(decorator, ast.Call):
  46. continue
  47. function = decorator.func
  48. if not (
  49. isinstance(function, ast.Attribute)
  50. and function.attr == "route"
  51. and isinstance(function.value, ast.Name)
  52. and function.value.id == "bp"
  53. and decorator.args
  54. ):
  55. continue
  56. raw_path = ast.literal_eval(decorator.args[0])
  57. methods = ["GET"]
  58. for keyword in decorator.keywords:
  59. if keyword.arg == "methods":
  60. methods = ast.literal_eval(keyword.value)
  61. path, parameters = route_path(prefix + raw_path)
  62. summary = (ast.get_docstring(node) or node.name.replace("_", " ")).splitlines()[0]
  63. for method in methods:
  64. routes.append(
  65. {
  66. "path": path,
  67. "method": method.lower(),
  68. "tag": module,
  69. "operation_id": f"{module}_{node.name}_{method.lower()}",
  70. "summary": summary,
  71. "parameters": parameters,
  72. }
  73. )
  74. return sorted(routes, key=lambda item: (str(item["path"]), str(item["method"])))
  75. def render(routes: list[dict[str, object]]) -> str:
  76. by_path: dict[str, list[dict[str, object]]] = defaultdict(list)
  77. for route in routes:
  78. by_path[str(route["path"])].append(route)
  79. lines = [
  80. "openapi: 3.1.0",
  81. "info:",
  82. ' title: "DataOps Platform API(当前代码基线)"',
  83. ' version: "2026-07-16"',
  84. ' description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"',
  85. f"x-route-count: {len(routes)}",
  86. "servers:",
  87. ' - url: "http://localhost:15500"',
  88. ' description: "全本地隔离测试后端"',
  89. "tags:",
  90. ]
  91. for tag in PREFIXES:
  92. lines.extend([f" - name: {tag}", f" description: {quoted(PREFIXES[tag])}"])
  93. lines.append("paths:")
  94. for path, operations in sorted(by_path.items()):
  95. lines.append(f" {quoted(path)}:")
  96. for operation in operations:
  97. lines.extend(
  98. [
  99. f" {operation['method']}:",
  100. f" tags: [{operation['tag']}]",
  101. f" operationId: {operation['operation_id']}",
  102. f" summary: {quoted(str(operation['summary']))}",
  103. f" x-source: {quoted('app/api/' + str(operation['tag']) + '/routes.py')}",
  104. ]
  105. )
  106. parameters = operation["parameters"]
  107. if parameters:
  108. lines.append(" parameters:")
  109. for parameter in parameters:
  110. lines.extend(
  111. [
  112. f" - name: {parameter['name']}",
  113. " in: path",
  114. " required: true",
  115. " schema:",
  116. f" type: {parameter['type']}",
  117. ]
  118. )
  119. if operation["method"] in {"post", "put", "patch"}:
  120. lines.extend(
  121. [
  122. " requestBody:",
  123. " required: false",
  124. " content:",
  125. " application/json:",
  126. " schema:",
  127. " type: object",
  128. " additionalProperties: true",
  129. ]
  130. )
  131. lines.extend(
  132. [
  133. " responses:",
  134. ' "200":',
  135. ' description: "请求已由当前实现处理"',
  136. " content:",
  137. " application/json:",
  138. " schema:",
  139. " $ref: '#/components/schemas/ApiEnvelope'",
  140. " default:",
  141. ' description: "错误响应"',
  142. " content:",
  143. " application/json:",
  144. " schema:",
  145. " $ref: '#/components/schemas/ApiEnvelope'",
  146. ]
  147. )
  148. lines.extend(
  149. [
  150. "components:",
  151. " schemas:",
  152. " ApiEnvelope:",
  153. " type: object",
  154. " additionalProperties: true",
  155. " properties:",
  156. " success:",
  157. " type: boolean",
  158. " code:",
  159. " type: integer",
  160. " message:",
  161. " type: string",
  162. " data: {}",
  163. " timestamp:",
  164. " type: integer",
  165. " securitySchemes:",
  166. " bearerAuth:",
  167. " type: http",
  168. " scheme: bearer",
  169. " bearerFormat: JWT",
  170. " description: \"下一阶段统一认证方案;当前路由尚未全部接入。\"",
  171. ]
  172. )
  173. return "\n".join(lines) + "\n"
  174. def main(output: Path = OUTPUT) -> None:
  175. routes = extract_routes()
  176. if not routes:
  177. raise SystemExit("No Flask routes found")
  178. output.parent.mkdir(parents=True, exist_ok=True)
  179. output.write_text(render(routes), encoding="utf-8")
  180. try:
  181. label = output.relative_to(ROOT)
  182. except ValueError:
  183. label = output
  184. print(f"Generated {label} with {len(routes)} operations")
  185. if __name__ == "__main__":
  186. parser = argparse.ArgumentParser(description=__doc__)
  187. parser.add_argument("--output", type=Path, default=OUTPUT)
  188. args = parser.parse_args()
  189. main(args.output)