openapi.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. """自定义 OpenAPI Schema 生成。
  2. FastAPI 默认根据路由参数自动生成 OpenAPI 时,不会感知 exception_handlers,
  3. 导致文档里的错误响应(422 等)使用内置 HTTPValidationError 模板,
  4. 与项目实际返回的统一信封 {code, data, msg} 不一致。
  5. 本模块在应用初始化阶段覆写 app.openapi 函数,让所有错误响应文档与运行时返回保持一致。
  6. """
  7. from __future__ import annotations
  8. from typing import Any
  9. from fastapi import FastAPI
  10. from fastapi.openapi.utils import get_openapi
  11. from app.response import ResponseCode
  12. # 需要在 OpenAPI 文档中替换的错误状态码 → (业务码, 描述, data 示例)
  13. # 与 app/exception_handlers.py 中的处理器返回值保持一致
  14. _ERROR_STATUS_MAP: dict[str, tuple[ResponseCode, str, Any]] = {
  15. "400": (
  16. ResponseCode.REQUEST_INVALID,
  17. "请求格式不正确",
  18. None,
  19. ),
  20. "404": (
  21. ResponseCode.NOT_FOUND,
  22. "资源不存在",
  23. None,
  24. ),
  25. "405": (
  26. ResponseCode.METHOD_NOT_ALLOWED,
  27. "请求方法不允许",
  28. None,
  29. ),
  30. "422": (
  31. ResponseCode.PARAM_INVALID,
  32. "参数验证失败",
  33. [
  34. {
  35. "type": "greater_than_equal",
  36. "loc": ["query", "page"],
  37. "msg": "Input should be greater than or equal to 1",
  38. "input": "-1",
  39. "ctx": {"ge": 1},
  40. }
  41. ],
  42. ),
  43. "500": (
  44. ResponseCode.INTERNAL_ERROR,
  45. "服务器内部错误",
  46. {"request_id": "uuid-for-tracing"},
  47. ),
  48. "502": (
  49. ResponseCode.UPSTREAM_ERROR,
  50. "上游服务调用失败(Zendesk 等)",
  51. None,
  52. ),
  53. "504": (
  54. ResponseCode.UPSTREAM_TIMEOUT,
  55. "上游服务响应超时",
  56. None,
  57. ),
  58. }
  59. def _build_error_schema_component() -> dict[str, Any]:
  60. """构造统一错误响应的 OpenAPI schema 组件。"""
  61. return {
  62. "type": "object",
  63. "properties": {
  64. "code": {
  65. "type": "integer",
  66. "description": "业务错误码(非 0 表示失败)",
  67. "example": ResponseCode.PARAM_INVALID.code,
  68. },
  69. "data": {
  70. "description": (
  71. "失败时的附加数据:参数验证失败时为错误字段列表,"
  72. "服务器内部错误时含 request_id,其他错误一般为 null"
  73. ),
  74. "nullable": True,
  75. "example": [
  76. {
  77. "type": "greater_than_equal",
  78. "loc": ["query", "page"],
  79. "msg": "Input should be greater than or equal to 1",
  80. "input": "-1",
  81. "ctx": {"ge": 1},
  82. }
  83. ],
  84. },
  85. "msg": {
  86. "type": "string",
  87. "description": "错误描述",
  88. "example": ResponseCode.PARAM_INVALID.msg,
  89. },
  90. },
  91. "required": ["code", "msg"],
  92. "title": "ApiErrorResponse",
  93. }
  94. def _build_response_for(
  95. code: ResponseCode, description: str, data_example: Any
  96. ) -> dict[str, Any]:
  97. """为某个具体错误状态码构造 OpenAPI 响应对象(带 example,便于 ReDoc 展示)。"""
  98. return {
  99. "description": f"{description}(业务码 {code.code})",
  100. "content": {
  101. "application/json": {
  102. "schema": {"$ref": "#/components/schemas/ApiErrorResponse"},
  103. "example": {
  104. "code": code.code,
  105. "data": data_example,
  106. "msg": code.msg,
  107. },
  108. }
  109. },
  110. }
  111. def setup_custom_openapi(app: FastAPI) -> None:
  112. """覆写 app.openapi,让所有错误响应文档使用统一信封格式。
  113. 应在应用初始化阶段调用一次(main.py 中 register_exception_handlers 之后)。
  114. """
  115. def custom_openapi() -> dict[str, Any]:
  116. if app.openapi_schema:
  117. return app.openapi_schema
  118. schema = get_openapi(
  119. title=app.title,
  120. version=app.version,
  121. description=app.description,
  122. routes=app.routes,
  123. )
  124. # 注册统一错误响应组件
  125. components = schema.setdefault("components", {}).setdefault("schemas", {})
  126. components["ApiErrorResponse"] = _build_error_schema_component()
  127. # 替换所有路径下的错误响应
  128. for path_item in schema.get("paths", {}).values():
  129. for operation in path_item.values():
  130. if not isinstance(operation, dict):
  131. continue
  132. responses = operation.get("responses", {})
  133. for status_code, (
  134. code,
  135. description,
  136. data_example,
  137. ) in _ERROR_STATUS_MAP.items():
  138. if status_code in responses:
  139. responses[status_code] = _build_response_for(
  140. code, description, data_example
  141. )
  142. app.openapi_schema = schema
  143. return schema
  144. app.openapi = custom_openapi # type: ignore[method-assign]