| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- #!/usr/bin/env python3
- """Generate the current Flask route inventory as an OpenAPI 3.1 contract."""
- from __future__ import annotations
- import ast
- import argparse
- import json
- import re
- from collections import defaultdict
- from pathlib import Path
- ROOT = Path(__file__).resolve().parents[1]
- OUTPUT = ROOT / "docs" / "architecture" / "OPENAPI.yaml"
- PREFIXES = {
- "meta_data": "/api/meta",
- "data_interface": "/api/interface",
- "graph": "/api/graph",
- "system": "/api/system",
- "data_source": "/api/datasource",
- "data_flow": "/api/dataflow",
- "business_domain": "/api/bd",
- "data_factory": "/api/datafactory",
- "data_service": "/api/dataservice",
- }
- def quoted(value: str) -> str:
- return json.dumps(value, ensure_ascii=False)
- def route_path(raw_path: str) -> tuple[str, list[dict[str, str]]]:
- parameters: list[dict[str, str]] = []
- def replace(match: re.Match[str]) -> str:
- converter = match.group(1) or "string"
- name = match.group(2)
- schema_type = "integer" if converter in {"int", "float"} else "string"
- parameters.append({"name": name, "type": schema_type})
- return "{" + name + "}"
- return re.sub(r"<(?:([a-zA-Z_]+):)?([a-zA-Z_][a-zA-Z0-9_]*)>", replace, raw_path), parameters
- def extract_routes() -> list[dict[str, object]]:
- routes: list[dict[str, object]] = []
- for module, prefix in PREFIXES.items():
- for route_file in sorted((ROOT / "app" / "api" / module).glob("*.py")):
- if route_file.name == "__init__.py":
- continue
- tree = ast.parse(route_file.read_text(encoding="utf-8"))
- for node in tree.body:
- if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
- continue
- for decorator in node.decorator_list:
- if not isinstance(decorator, ast.Call):
- continue
- function = decorator.func
- if not (
- isinstance(function, ast.Attribute)
- and function.attr == "route"
- and isinstance(function.value, ast.Name)
- and function.value.id == "bp"
- and decorator.args
- ):
- continue
- raw_path = ast.literal_eval(decorator.args[0])
- methods = ["GET"]
- for keyword in decorator.keywords:
- if keyword.arg == "methods":
- methods = ast.literal_eval(keyword.value)
- path, parameters = route_path(prefix + raw_path)
- summary = (ast.get_docstring(node) or node.name.replace("_", " ")).splitlines()[0]
- for method in methods:
- routes.append(
- {
- "path": path,
- "method": method.lower(),
- "tag": module,
- "operation_id": f"{module}_{node.name}_{method.lower()}",
- "summary": summary,
- "parameters": parameters,
- }
- )
- return sorted(routes, key=lambda item: (str(item["path"]), str(item["method"])))
- def render(routes: list[dict[str, object]]) -> str:
- by_path: dict[str, list[dict[str, object]]] = defaultdict(list)
- for route in routes:
- by_path[str(route["path"])].append(route)
- lines = [
- "openapi: 3.1.0",
- "info:",
- ' title: "DataOps Platform API(当前代码基线)"',
- ' version: "2026-07-16"',
- ' description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"',
- f"x-route-count: {len(routes)}",
- "servers:",
- ' - url: "http://localhost:15500"',
- ' description: "全本地隔离测试后端"',
- "tags:",
- ]
- for tag in PREFIXES:
- lines.extend([f" - name: {tag}", f" description: {quoted(PREFIXES[tag])}"])
- lines.append("paths:")
- for path, operations in sorted(by_path.items()):
- lines.append(f" {quoted(path)}:")
- for operation in operations:
- lines.extend(
- [
- f" {operation['method']}:",
- f" tags: [{operation['tag']}]",
- f" operationId: {operation['operation_id']}",
- f" summary: {quoted(str(operation['summary']))}",
- f" x-source: {quoted('app/api/' + str(operation['tag']) + '/routes.py')}",
- ]
- )
- parameters = operation["parameters"]
- if parameters:
- lines.append(" parameters:")
- for parameter in parameters:
- lines.extend(
- [
- f" - name: {parameter['name']}",
- " in: path",
- " required: true",
- " schema:",
- f" type: {parameter['type']}",
- ]
- )
- if operation["method"] in {"post", "put", "patch"}:
- lines.extend(
- [
- " requestBody:",
- " required: false",
- " content:",
- " application/json:",
- " schema:",
- " type: object",
- " additionalProperties: true",
- ]
- )
- lines.extend(
- [
- " responses:",
- ' "200":',
- ' description: "请求已由当前实现处理"',
- " content:",
- " application/json:",
- " schema:",
- " $ref: '#/components/schemas/ApiEnvelope'",
- " default:",
- ' description: "错误响应"',
- " content:",
- " application/json:",
- " schema:",
- " $ref: '#/components/schemas/ApiEnvelope'",
- ]
- )
- lines.extend(
- [
- "components:",
- " schemas:",
- " ApiEnvelope:",
- " type: object",
- " additionalProperties: true",
- " properties:",
- " success:",
- " type: boolean",
- " code:",
- " type: integer",
- " message:",
- " type: string",
- " data: {}",
- " timestamp:",
- " type: integer",
- " securitySchemes:",
- " bearerAuth:",
- " type: http",
- " scheme: bearer",
- " bearerFormat: JWT",
- " description: \"下一阶段统一认证方案;当前路由尚未全部接入。\"",
- ]
- )
- return "\n".join(lines) + "\n"
- def main(output: Path = OUTPUT) -> None:
- routes = extract_routes()
- if not routes:
- raise SystemExit("No Flask routes found")
- output.parent.mkdir(parents=True, exist_ok=True)
- output.write_text(render(routes), encoding="utf-8")
- try:
- label = output.relative_to(ROOT)
- except ValueError:
- label = output
- print(f"Generated {label} with {len(routes)} operations")
- if __name__ == "__main__":
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--output", type=Path, default=OUTPUT)
- args = parser.parse_args()
- main(args.output)
|