import inspect from functools import wraps from typing import Any, Callable from fastapi import APIRouter from fastapi.datastructures import DefaultPlaceholder from fastapi.responses import JSONResponse from fastapi.routing import APIRoute, get_typed_return_annotation from starlette.responses import Response as StarletteResponse from app.schemas.common import JsonData from app.schemas.response import Response, ValidationIssue ERROR_RESPONSES: dict[int, dict[str, Any]] = { 400: {"model": Response[None], "description": "请求错误"}, 401: {"model": Response[None], "description": "未认证"}, 403: {"model": Response[None], "description": "无权限"}, 404: {"model": Response[None], "description": "资源不存在"}, 409: {"model": Response[None], "description": "资源冲突"}, 422: { "model": Response[list[ValidationIssue]], "description": "请求参数校验失败", }, 500: {"model": Response[None], "description": "服务器内部错误"}, } RAW_RESPONSE_OPENAPI_KEY = "x-moviepilot-raw-response" class ResponseAPIRoute(APIRoute): """为普通 JSON 接口统一声明并生成 ``Response[T]`` 响应。""" def __init__( self, path: str, endpoint: Callable[..., Any], **kwargs: Any, ) -> None: """根据原始响应模型决定是否包装接口定义及运行时返回值。""" response_model = kwargs.get("response_model") response_class = kwargs.get("response_class", JSONResponse) status_code = kwargs.get("status_code") openapi_extra = kwargs.get("openapi_extra") or {} force_raw = bool(openapi_extra.get(RAW_RESPONSE_OPENAPI_KEY)) if isinstance(response_model, DefaultPlaceholder): inferred_model = get_typed_return_annotation(endpoint) if self._is_native_response_model(inferred_model): response_model = None else: response_model = inferred_model or JsonData if response_model is Any: response_model = JsonData if response_model is Response: response_model = Response[JsonData] kwargs["response_model"] = response_model should_wrap = self._should_wrap_response( response_model=response_model, response_class=response_class, status_code=status_code, force_raw=force_raw, ) if should_wrap: kwargs["response_model"] = Response[response_model] endpoint = self._wrap_endpoint(endpoint) kwargs["responses"] = self._merge_error_responses( kwargs.get("responses") ) super().__init__(path=path, endpoint=endpoint, **kwargs) @staticmethod def _should_wrap_response( response_model: Any, response_class: Any, status_code: int | None, force_raw: bool, ) -> bool: """判断当前路由是否属于需要统一封装的普通 JSON 接口。""" if force_raw or response_model is None or status_code in {204, 304}: return False resolved_response_class = ( response_class.value if isinstance(response_class, DefaultPlaceholder) else response_class ) try: if not issubclass(resolved_response_class, JSONResponse): return False except TypeError: return False return not ResponseAPIRoute._is_response_model(response_model) @staticmethod def _is_response_model(response_model: Any) -> bool: """判断声明模型是否已经是统一响应模型。""" try: return issubclass(response_model, Response) except TypeError: return False @staticmethod def _is_native_response_model(response_model: Any) -> bool: """判断返回注解是否声明为 Starlette 原生响应。""" try: return issubclass(response_model, StarletteResponse) except TypeError: return False @staticmethod def _merge_error_responses( responses: dict[int | str, dict[str, Any]] | None, ) -> dict[int | str, dict[str, Any]]: """补齐统一错误模型,并保留端点已经显式声明的响应。""" merged_responses: dict[int | str, dict[str, Any]] = dict(ERROR_RESPONSES) merged_responses.update(responses or {}) return merged_responses @staticmethod def _wrap_endpoint(endpoint: Callable[..., Any]) -> Callable[..., Any]: """包装端点返回值,同时保持原函数签名供 FastAPI 注入依赖。""" if inspect.iscoroutinefunction(endpoint): @wraps(endpoint) async def async_endpoint(*args: Any, **kwargs: Any) -> Any: """异步调用端点并封装普通业务数据。""" result = await endpoint(*args, **kwargs) return ResponseAPIRoute._wrap_result(result) return async_endpoint @wraps(endpoint) def sync_endpoint(*args: Any, **kwargs: Any) -> Any: """同步调用端点并封装普通业务数据。""" result = endpoint(*args, **kwargs) return ResponseAPIRoute._wrap_result(result) return sync_endpoint @staticmethod def _wrap_result(result: Any) -> Any: """保留已封装或原生响应,其余结果写入统一响应的数据区域。""" if isinstance(result, (Response, StarletteResponse)): return result return Response(success=True, data=result) class ResponseAPIRouter(APIRouter): """默认使用统一响应路由类的 API 路由器。""" def __init__(self, **kwargs: Any) -> None: """初始化路由器并允许调用方显式覆盖路由类。""" kwargs.setdefault("route_class", ResponseAPIRoute) super().__init__(**kwargs)