"""从登记路由工厂生成 OpenAPI 与客户端外壳;类型由后续步骤生成。 先核对全部登记与输出位置,再导入工厂并生成内存合同,最后写入。 不启动应用、不连接数据库或读取凭据。 """ from __future__ import annotations import argparse import importlib import json import keyword import sys from collections.abc import Callable from pathlib import Path from typing import Any 仓库根 = Path(__file__).resolve().parent.parent 登记文件 = 仓库根 / "工具" / "生成输入.json" _客户端路径 = "web/src/接口/生成/客户端.ts" _客户端正文 = ( "// 由工具/生成接口.py生成;稳定外壳来自同一OpenAPI。\n" 'import createClient from "openapi-fetch";\n' 'import type { paths } from "./类型";\n' 'import { 传输 } from "../传输";\n\n' "export const 客户端 = createClient({\n" " baseUrl: globalThis.location.origin, fetch: 传输,\n" ' credentials: "same-origin"\n' "});\n" ) class 生成错误(ValueError): """仅携带可安全展示的登记或合同错误,不包含原始工厂异常。""" def 已登记输入() -> dict[str, Any]: """缺登记与坏登记都拒绝;显式空资源表仍是合法的无输出登记。""" try: 数据 = json.loads(登记文件.read_text(encoding="utf-8")) except FileNotFoundError: raise 生成错误("生成输入登记不存在") from None except (OSError, UnicodeError, ValueError): raise 生成错误("生成输入登记无法读取或不是合法 JSON") from None if ( not isinstance(数据, dict) or type(数据.get("schema_version")) is not int or 数据.get("schema_version") != 1 or not isinstance(数据.get("资源"), list) ): raise 生成错误("生成输入登记需要 schema_version=1 和资源列表") return 数据 def _输出位置(地址: Any) -> Path: if not isinstance(地址, str) or not 地址.strip() or "\\" in 地址: raise 生成错误("输出必须为仓库内相对文件路径") 路径 = Path(地址) if 路径.is_absolute() or ".." in 路径.parts or 路径 == Path("."): raise 生成错误("输出拒绝绝对路径、父目录跳转或仓库根目录") try: 输出 = (仓库根 / 路径).resolve() except (OSError, ValueError, RuntimeError): raise 生成错误("输出路径无法解析") from None if not 输出.is_relative_to(仓库根.resolve()) or 输出 == 仓库根.resolve(): raise 生成错误("输出经软链接解析后超出仓库范围") if 输出.is_dir() or any(p.exists() and not p.is_dir() for p in 输出.parents): raise 生成错误("输出位置被现有目录或非目录父路径占用") return 输出 def 预检输入(输入: dict[str, Any]) -> list[tuple[str, str, Path]]: """所有条目在导入或写入前校验;固定客户端输出同样不能逃逸。""" if ( not isinstance(输入, dict) or type(输入.get("schema_version")) is not int or 输入.get("schema_version") != 1 or not isinstance(输入.get("资源"), list) ): raise 生成错误("生成输入登记需要 schema_version=1 和资源列表") 计划 = [] 目标集 = {_输出位置(_客户端路径)} if 输入["资源"] else set() for 序, 条目 in enumerate(输入["资源"], 1): if not isinstance(条目, dict) or 条目.get("格式") != "OpenAPI": raise 生成错误(f"第 {序} 项必须是 OpenAPI 生成登记") 工厂 = 条目.get("工厂") if not isinstance(工厂, str) or 工厂.count(":") != 1: raise 生成错误(f"第 {序} 项工厂必须使用 module:symbol 格式") 模块, 名称 = 工厂.split(":") if not all(p.isidentifier() and not keyword.iskeyword(p) for p in [*模块.split("."), 名称]): raise 生成错误(f"第 {序} 项工厂必须使用合法 module:symbol 格式") 输出 = _输出位置(条目.get("输出")) if any(输出 == p or 输出 in p.parents or p in 输出.parents for p in 目标集): raise 生成错误(f"第 {序} 项输出与其它产物重复或父子路径冲突") 目标集.add(输出) 计划.append((模块, 名称, 输出)) return 计划 def 生成接口(输入: dict[str, Any]) -> list[Path]: 计划 = 预检输入(输入) 工厂组: list[tuple[Callable, Path]] = [] for 序, (模块, 名称, 输出) in enumerate(计划, 1): try: 工厂 = getattr(importlib.import_module(模块), 名称) except Exception: raise 生成错误(f"第 {序} 项工厂无法导入") from None if not callable(工厂): raise 生成错误(f"第 {序} 项工厂不是可调用对象") 工厂组.append((工厂, 输出)) 产物 = {} for 序, (工厂, 输出) in enumerate(工厂组, 1): try: 合同 = 工厂() except Exception: raise 生成错误(f"第 {序} 项工厂执行失败") from None if ( not isinstance(合同, dict) or not isinstance(合同.get("openapi"), str) or not 合同["openapi"].strip() ): raise 生成错误(f"第 {序} 项工厂未返回 OpenAPI 合同") try: 产物[输出] = json.dumps(合同, ensure_ascii=False, indent=2, allow_nan=False) + "\n" except (TypeError, ValueError): raise 生成错误(f"第 {序} 项合同无法序列化") from None if 产物: 产物[_输出位置(_客户端路径)] = _客户端正文 for 输出, 正文 in 产物.items(): 输出.parent.mkdir(parents=True, exist_ok=True) 输出.write_text(正文, encoding="utf-8") return list(产物) def 主() -> int: 解析器 = argparse.ArgumentParser( description="生成 OpenAPI 与客户端外壳;类型.ts 由后续 openapi-typescript 步骤生成" ) 解析器.parse_args() try: 产物 = 生成接口(已登记输入()) except 生成错误 as 错误: print(f"生成接口失败,未生成合同:{错误}", file=sys.stderr) return 1 except OSError: print("生成接口失败:无法写入已校验的输出位置", file=sys.stderr) return 1 if not 产物: print("生成接口:显式空登记,未生成任何合同") for 输出 in 产物: print(f"生成接口:{输出.relative_to(仓库根)}") return 0 if __name__ == "__main__": raise SystemExit(主())