"""把三个服务的接口合并导出成一份 openapi.json(供 Apifox / Postman 快速测试) 仓库出三个进程、三个端口,但对外只想给一份文档,所以这里把三份 FastAPI 生成的 spec 合成一份,并补上 FastAPI 自己表达不出来的两件事: 1. **每个接口属于哪个服务**:给每个 operation 单独写 `servers`(OpenAPI 允许 operation 级覆盖),导入后每条请求的 base URL 就是它真正的端口,不用手动切换。 2. **Bearer 鉴权**:`require_bearer_token` 是自己读 Authorization 头的普通依赖, 不是 FastAPI 的 security scheme,生成的 spec 里完全看不见。这里遍历路由的依赖 树识别出哪些接口真的要 token(而不是按路径猜),补上 securitySchemes + security。 `/health` 三个服务都有且路径相同 —— OpenAPI 的 paths 是以路径为键的,无法放三份。 合并成一条:servers 列出三个服务,响应 schema 用 anyOf 罩住三种 HealthData, 描述里写明按 server 切换。 用法: .venv/Scripts/python.exe scripts/export_openapi.py # 写回 openapi.json .venv/Scripts/python.exe scripts/export_openapi.py --check # 只校验是否已是最新 tests/test_openapi_export.py 会跑 --check 的等价断言:加了新接口忘了重新导出, 测试会直接失败,避免这份文档慢慢变成过期文档。 """ from __future__ import annotations import argparse import copy import json import sys from dataclasses import dataclass from pathlib import Path from typing import Any sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from fastapi import FastAPI # noqa: E402 from fastapi.routing import APIRoute # noqa: E402 OUTPUT_PATH = Path(__file__).resolve().parent.parent / "openapi.json" _SECURITY_SCHEME = "BearerAuth" _INFO_DESCRIPTION = """乐天 / ラクマ 抓取 + 下单交易 HTTP API(三个服务合并成一份文档)。 - **抓取服务** `:31107` —— 匿名无状态,可多开实例。搜索/分类/商品/店铺,含 ラクマ。 - **交易服务(有状态端)** `:31108` —— 持账号登录态,加购与登录态管理,**只能单实例**。 - **下单任务网关** `:31109` —— 下单任务队列与状态查询,本地 worker 长轮询领任务,**只能单实例**。 每个接口的 `servers` 已按所属服务单独标注,导入后不需要手动切 base URL。 除 `/health` 外全部需要请求头 `Authorization: Bearer `。 响应统一是 `{success, msg, data, code}` 信封,字段与错误码说明见项目 README.md。 本文件由 scripts/export_openapi.py 生成,不要手改。""" @dataclass(frozen=True) class Service: """一个部署单元在文档里的身份""" key: str # 用于 operationId 前缀与 schema 重名时的前缀 label: str # 展示名(tag 前缀 / Apifox 目录名) module: str # 入口模块,取其 create_app port: int @property def url(self) -> str: return f"http://127.0.0.1:{self.port}" SERVICES = ( Service(key="scraping", label="抓取服务", module="app.scraping.main", port=31107), Service(key="trading", label="交易服务", module="app.trading.main", port=31108), Service(key="gateway", label="下单网关", module="app.gateway.main", port=31109), ) _METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace") def _create_app(service: Service) -> FastAPI: """按模块名取 create_app 并建应用(不跑 lifespan,无副作用)""" module = __import__(service.module, fromlist=["create_app"]) return module.create_app() def _api_routes(app: FastAPI) -> list[APIRoute]: """摊平取出所有 APIRoute 新版 FastAPI(0.140)的 include_router 不再把子路由摊到 app.routes 上,而是包成 _IncludedRouter,真正的路由挂在它的 original_router.routes 上。不递归下去的话 一条 APIRoute 都拿不到,鉴权也就全都识别不出来。 """ found: list[APIRoute] = [] def walk(routes: list[Any]) -> None: for route in routes: if isinstance(route, APIRoute): found.append(route) continue nested = getattr(route, "routes", None) if nested is None: inner = getattr(route, "original_router", None) nested = getattr(inner, "routes", None) if nested: walk(list(nested)) walk(list(app.routes)) return found def _bearer_protected(app: FastAPI) -> set[tuple[str, str]]: """遍历依赖树,找出真的挂了 require_bearer_token 的 (path, method) 不按路径规律猜:以后哪条接口加/去掉鉴权,文档要跟着自动变。 """ from app.shared.api import require_bearer_token def uses_token(dependant: Any, seen: set[int]) -> bool: if id(dependant) in seen: return False seen.add(id(dependant)) if getattr(dependant, "call", None) is require_bearer_token: return True return any(uses_token(sub, seen) for sub in dependant.dependencies) protected: set[tuple[str, str]] = set() for route in _api_routes(app): if uses_token(route.dependant, set()): protected.update((route.path, method.lower()) for method in route.methods) return protected def _rewrite_refs(node: Any, rename: dict[str, str]) -> Any: """递归把 $ref 指向的 schema 名按 rename 表替换""" if isinstance(node, dict): result = {} for key, value in node.items(): if key == "$ref" and isinstance(value, str): name = value.rsplit("/", 1)[-1] if value.startswith("#/components/schemas/") and name in rename: result[key] = f"#/components/schemas/{rename[name]}" continue result[key] = _rewrite_refs(value, rename) return result if isinstance(node, list): return [_rewrite_refs(item, rename) for item in node] return node def _merge_schemas( merged: dict[str, Any], incoming: dict[str, Any], service: Service ) -> dict[str, str]: """把一个服务的 schemas 并进总表,返回该服务需要的重命名表 同名同内容(如 ApiResponse 信封派生出的公共模型、HTTPValidationError)直接复用; 同名不同内容才加服务前缀——三个服务的模型确实可能撞名,但不能让后来者悄悄覆盖前者。 """ rename: dict[str, str] = {} for name, schema in incoming.items(): if name not in merged: merged[name] = schema continue if merged[name] == schema: continue rename[name] = f"{service.key.capitalize()}{name}" for original, renamed in rename.items(): merged[renamed] = incoming[original] return rename def _response_schemas(operation: dict[str, Any]) -> dict[str, Any]: """取 200 响应的 JSON schema(没有则空 dict)""" content = operation.get("responses", {}).get("200", {}).get("content", {}) return content.get("application/json", {}).get("schema", {}) or {} def _merge_same_path(existing: dict[str, Any], incoming: dict[str, Any]) -> None: """同路径同方法(只有 /health):并 servers、并响应 schema、拼描述""" for server in incoming.get("servers", []): if server not in existing.setdefault("servers", []): existing["servers"].append(server) existing_schema = _response_schemas(existing) incoming_schema = _response_schemas(incoming) if existing_schema and incoming_schema and existing_schema != incoming_schema: options = existing_schema.get("anyOf", [existing_schema]) if incoming_schema not in options: options = [*options, incoming_schema] existing["responses"]["200"]["content"]["application/json"]["schema"] = { "anyOf": options, "title": "各服务的健康检查响应", } incoming_description = incoming.get("description", "").strip() if incoming_description and incoming_description not in existing.get("description", ""): existing["description"] = ( f"{existing.get('description', '').rstrip()}\n\n---\n\n{incoming_description}" ) def build_spec() -> dict[str, Any]: """合并三个服务的 OpenAPI 文档""" paths: dict[str, Any] = {} schemas: dict[str, Any] = {} tags: list[dict[str, str]] = [] for service in SERVICES: app = _create_app(service) spec = copy.deepcopy(app.openapi()) protected = _bearer_protected(app) rename = _merge_schemas( schemas, spec.get("components", {}).get("schemas", {}), service ) service_paths = _rewrite_refs(spec.get("paths", {}), rename) # 鉴权是从路由依赖树里认出来的,路径对不上就等于漏标——宁可构建失败, # 也不要导出一份「看起来不需要 token」的文档 unmatched = sorted( f"{method.upper()} {path}" for path, method in protected if method not in service_paths.get(path, {}) ) if unmatched: raise RuntimeError( f"{service.key}: 这些需要鉴权的路由在 OpenAPI 里找不到对应 operation:" f"{unmatched}(include_router 加了 prefix?)" ) for path, path_item in service_paths.items(): for method, operation in path_item.items(): if method not in _METHODS: continue operation["servers"] = [{"url": service.url, "description": service.label}] # operationId 必须全局唯一:三个服务的 /health 生成的都是 health_health_get operation["operationId"] = f"{service.key}_{operation.get('operationId', method)}" raw_tags = operation.get("tags") or ["default"] operation["tags"] = [f"{service.label}/{tag}" for tag in raw_tags] for tag in operation["tags"]: if all(item["name"] != tag for item in tags): tags.append({"name": tag, "description": f"{service.label}({service.url})"}) # Apifox 目录:与 tag 一致,导入后直接按服务分组 operation["x-apifox-folder"] = operation["tags"][0] if (path, method) in protected: operation["security"] = [{_SECURITY_SCHEME: []}] if path not in paths: paths[path] = path_item continue for method, operation in path_item.items(): if method in paths[path]: _merge_same_path(paths[path][method], operation) else: paths[path][method] = operation return { "openapi": "3.1.0", "info": { "title": "Rakuten API(抓取 / 交易 / 下单网关)", "version": "0.1.0", "description": _INFO_DESCRIPTION, "x-apifox-folder": "Rakuten", }, "servers": [ {"url": service.url, "description": f"{service.label} :{service.port}"} for service in SERVICES ], "tags": tags, "paths": paths, "components": { "schemas": schemas, "securitySchemes": { _SECURITY_SCHEME: { "type": "http", "scheme": "bearer", "description": "值取配置项 RAKUTEN_BEARER_TOKEN;三个服务共用同一个 token", } }, }, } def dump(spec: dict[str, Any]) -> str: """固定序列化形式,便于 --check 直接比字符串""" return json.dumps(spec, ensure_ascii=False, indent=2, sort_keys=False) + "\n" def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="导出合并后的 openapi.json") parser.add_argument( "--check", action="store_true", help="只校验 openapi.json 是否与当前代码一致,不写文件;不一致时退出码 1", ) parser.add_argument("--output", default=str(OUTPUT_PATH)) args = parser.parse_args(argv) content = dump(build_spec()) output = Path(args.output) if args.check: current = output.read_text(encoding="utf-8") if output.exists() else "" if current == content: print(f"openapi.json 已是最新:{output}") return 0 print( f"openapi.json 与当前代码不一致:{output}\n" "请重新导出:.venv/Scripts/python.exe scripts/export_openapi.py", file=sys.stderr, ) return 1 output.write_text(content, encoding="utf-8") spec = json.loads(content) print(f"已写入 {output}") print(f"接口数:{sum(1 for item in spec['paths'].values() for _ in item)}(路径 {len(spec['paths'])} 条)") for path in spec["paths"]: methods = [m.upper() for m in spec["paths"][path] if m in _METHODS] print(f" {','.join(methods):6} {path}") return 0 if __name__ == "__main__": raise SystemExit(main())