mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 11:27:43 +08:00
454 lines
18 KiB
Python
454 lines
18 KiB
Python
import inspect
|
||
import json
|
||
from functools import wraps
|
||
from typing import Annotated, Any, Callable, Coroutine, Optional, cast, get_args, get_origin
|
||
|
||
from fastapi import APIRouter, Query, Request
|
||
from fastapi._compat import ModelField
|
||
from fastapi.datastructures import DefaultPlaceholder
|
||
from fastapi.dependencies.utils import get_typed_return_annotation
|
||
from fastapi.responses import JSONResponse
|
||
from fastapi.routing import APIRoute
|
||
from fastapi.utils import create_model_field
|
||
from starlette.responses import Response as StarletteResponse
|
||
|
||
from app.schemas.common import JsonData
|
||
from app.schemas.response import Response, ValidationIssue
|
||
|
||
ERROR_RESPONSES: dict[int | str, 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": "服务器内部错误"},
|
||
}
|
||
# 默认错误响应只用于生成 OpenAPI 文档。FastAPI 会为每个路由的每个带 model 的附加响应单独创建
|
||
# ModelField,并各自编译一套 pydantic 校验器与序列化器;全部路由改为共用下面这一组字段,文档输出不变。
|
||
# 构造路由时先传入去掉 model 的声明,使 FastAPI 不再逐路由创建字段,构造后再换回原声明并挂上共享字段。
|
||
_ERROR_RESPONSES_WITHOUT_MODEL: dict[int | str, dict[str, Any]] = {
|
||
status: {key: value for key, value in spec.items() if key != "model"}
|
||
for status, spec in ERROR_RESPONSES.items()
|
||
}
|
||
_SHARED_ERROR_RESPONSE_FIELDS: dict[int | str, ModelField] = {
|
||
status: create_model_field(
|
||
name=f"Response_{status}", type_=spec["model"], mode="serialization"
|
||
)
|
||
for status, spec in ERROR_RESPONSES.items()
|
||
}
|
||
RAW_RESPONSE_OPENAPI_KEY = "x-moviepilot-raw-response"
|
||
COLLECTION_PAGINATION_OPENAPI_KEY = "x-moviepilot-compatible-pagination"
|
||
COLLECTION_TOTAL_OPENAPI_KEY = "x-moviepilot-exact-total"
|
||
COLLECTION_TOTAL_HEADER = "X-Total-Count"
|
||
COLLECTION_RESULT_HEADER = "X-Result-Count"
|
||
COLLECTION_PAGE_HEADER = "X-Page"
|
||
COLLECTION_PAGE_SIZE_HEADER = "X-Page-Size"
|
||
COLLECTION_DEFAULT_PAGE_SIZE = 50
|
||
COLLECTION_MAX_PAGE_SIZE = 200
|
||
CompatiblePageParam = Annotated[
|
||
Optional[int],
|
||
Query(
|
||
ge=1,
|
||
description=(
|
||
"Optional one-based page for a legacy full-list endpoint. Omit both page and "
|
||
"count to keep the original unpaginated full result."
|
||
),
|
||
),
|
||
]
|
||
CompatibleCountParam = Annotated[
|
||
Optional[int],
|
||
Query(
|
||
ge=1,
|
||
le=COLLECTION_MAX_PAGE_SIZE,
|
||
description=(
|
||
"Optional page size for a legacy full-list endpoint. Supplying page or count "
|
||
f"activates pagination; an omitted count then uses {COLLECTION_DEFAULT_PAGE_SIZE}."
|
||
),
|
||
),
|
||
]
|
||
_COLLECTION_WINDOW_PARAMETERS = frozenset(
|
||
{"page", "count", "limit", "offset", "page_size", "max_results"}
|
||
)
|
||
_COLLECTION_RESPONSE_HEADERS = {
|
||
COLLECTION_RESULT_HEADER: {
|
||
"description": "Number of collection items serialized in this response body.",
|
||
"schema": {"type": "integer", "minimum": 0},
|
||
},
|
||
COLLECTION_PAGE_HEADER: {
|
||
"description": "Effective one-based page when request pagination is active.",
|
||
"schema": {"type": "integer", "minimum": 1},
|
||
},
|
||
COLLECTION_PAGE_SIZE_HEADER: {
|
||
"description": "Effective page size when request pagination is active.",
|
||
"schema": {"type": "integer", "minimum": 1},
|
||
},
|
||
}
|
||
|
||
|
||
def resolve_compatible_pagination(
|
||
page: Optional[int],
|
||
count: Optional[int],
|
||
) -> tuple[Optional[int], Optional[int]]:
|
||
"""解析兼容分页窗口;两项均省略时保留原全量查询语义。"""
|
||
if page is None and count is None:
|
||
return None, None
|
||
return page or 1, count or COLLECTION_DEFAULT_PAGE_SIZE
|
||
|
||
|
||
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))
|
||
force_collection_pagination = bool(
|
||
openapi_extra.get(COLLECTION_PAGINATION_OPENAPI_KEY)
|
||
)
|
||
endpoint_reports_collection_total = bool(
|
||
openapi_extra.get(COLLECTION_TOTAL_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
|
||
|
||
methods = {
|
||
str(method).upper()
|
||
for method in (kwargs.get("methods") or [])
|
||
}
|
||
endpoint_parameters = set(inspect.signature(endpoint).parameters)
|
||
collection_response = self._is_collection_response_model(response_model)
|
||
collection_window_parameters = endpoint_parameters & _COLLECTION_WINDOW_PARAMETERS
|
||
collection_parameter_defaults = self._parameter_defaults(
|
||
endpoint,
|
||
collection_window_parameters,
|
||
)
|
||
explicit_compatible_pagination = (
|
||
{"page", "count"}.issubset(endpoint_parameters)
|
||
and collection_parameter_defaults.get("page") is None
|
||
and collection_parameter_defaults.get("count") is None
|
||
)
|
||
optional_collection_pagination = bool(
|
||
collection_response
|
||
and ("GET" in methods or force_collection_pagination)
|
||
and explicit_compatible_pagination
|
||
and not endpoint_reports_collection_total
|
||
)
|
||
if collection_response:
|
||
kwargs["responses"] = self._merge_collection_response_headers(
|
||
kwargs.get("responses"),
|
||
include_total=(
|
||
optional_collection_pagination
|
||
or endpoint_reports_collection_total
|
||
),
|
||
)
|
||
|
||
self._collection_response = collection_response
|
||
self._optional_collection_pagination = optional_collection_pagination
|
||
self._collection_parameter_defaults = collection_parameter_defaults
|
||
|
||
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.__class_getitem__(response_model)
|
||
endpoint = self._wrap_endpoint(endpoint)
|
||
|
||
kwargs["responses"] = self._merge_error_responses(
|
||
kwargs.get("responses")
|
||
)
|
||
|
||
super().__init__(path=path, endpoint=endpoint, **kwargs)
|
||
self._attach_shared_error_response_fields()
|
||
|
||
def get_route_handler(
|
||
self,
|
||
) -> Callable[[Request], Coroutine[object, object, StarletteResponse]]:
|
||
"""在标准端点序列化后附加兼容列表分页与数量元数据。"""
|
||
original_handler = cast(
|
||
Callable[[Request], Coroutine[object, object, StarletteResponse]],
|
||
super().get_route_handler(),
|
||
)
|
||
if not self._collection_response:
|
||
return original_handler
|
||
|
||
async def collection_handler(request: Request) -> StarletteResponse:
|
||
"""保留列表响应体形状,并在显式请求时执行兼容切片。"""
|
||
response = await original_handler(request)
|
||
return self._apply_collection_contract(request, response)
|
||
|
||
return collection_handler
|
||
|
||
@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 _is_collection_response_model(response_model: Any) -> bool:
|
||
"""判断响应模型的业务数据是否为列表。"""
|
||
if response_model is None:
|
||
return False
|
||
generic_metadata = getattr(
|
||
response_model,
|
||
"__pydantic_generic_metadata__",
|
||
None,
|
||
)
|
||
if isinstance(generic_metadata, dict):
|
||
arguments = generic_metadata.get("args") or ()
|
||
if arguments:
|
||
response_model = arguments[0]
|
||
if get_origin(response_model) is list:
|
||
return True
|
||
if isinstance(response_model, type):
|
||
model_fields = getattr(response_model, "model_fields", None)
|
||
root_field = model_fields.get("root") if isinstance(model_fields, dict) else None
|
||
if root_field is not None and get_origin(root_field.annotation) is list:
|
||
return True
|
||
return get_origin(response_model) in {list, tuple} and bool(get_args(response_model))
|
||
|
||
@staticmethod
|
||
def _parameter_defaults(
|
||
endpoint: Callable[..., Any],
|
||
parameter_names: set[str],
|
||
) -> dict[str, Any]:
|
||
"""读取原生分页或限量参数的端点默认值,供响应元数据复用。"""
|
||
signature = inspect.signature(endpoint)
|
||
defaults: dict[str, Any] = {}
|
||
for name in parameter_names:
|
||
parameter = signature.parameters.get(name)
|
||
if parameter is None or parameter.default is inspect.Parameter.empty:
|
||
continue
|
||
defaults[name] = parameter.default
|
||
return defaults
|
||
|
||
@staticmethod
|
||
def _merge_collection_response_headers(
|
||
responses: dict[int | str, dict[str, Any]] | None,
|
||
*,
|
||
include_total: bool,
|
||
) -> dict[int | str, dict[str, Any]]:
|
||
"""为列表响应声明兼容数量头,并保留端点既有成功响应定义。"""
|
||
merged = dict(responses or {})
|
||
success_key: int | str = 200 if "200" not in merged else "200"
|
||
success_response = dict(merged.get(success_key) or {})
|
||
headers = dict(success_response.get("headers") or {})
|
||
headers.update(_COLLECTION_RESPONSE_HEADERS)
|
||
if include_total:
|
||
headers[COLLECTION_TOTAL_HEADER] = {
|
||
"description": (
|
||
"Exact collection size before optional compatibility pagination. This header "
|
||
"is omitted when an upstream-native page or limit does not expose a total."
|
||
),
|
||
"schema": {"type": "integer", "minimum": 0},
|
||
}
|
||
success_response["headers"] = headers
|
||
merged[success_key] = success_response
|
||
return merged
|
||
|
||
def _apply_collection_contract(
|
||
self,
|
||
request: Request,
|
||
response: StarletteResponse,
|
||
) -> StarletteResponse:
|
||
"""对已序列化列表应用显式分页,同时通过响应头报告数量元数据。"""
|
||
content_type = response.headers.get("content-type", "").lower()
|
||
body = getattr(response, "body", None)
|
||
if (
|
||
response.status_code >= 400
|
||
or body is None
|
||
or not ("application/json" in content_type or "+json" in content_type)
|
||
):
|
||
return response
|
||
try:
|
||
payload = json.loads(body)
|
||
except (TypeError, ValueError, json.JSONDecodeError):
|
||
return response
|
||
items = payload.get("data") if isinstance(payload, dict) else payload
|
||
if not isinstance(items, list):
|
||
return response
|
||
|
||
total_count = len(items)
|
||
page: Optional[int] = None
|
||
page_size: Optional[int] = None
|
||
if self._optional_collection_pagination and (
|
||
"page" in request.query_params or "count" in request.query_params
|
||
):
|
||
page, page_size = resolve_compatible_pagination(
|
||
int(request.query_params["page"])
|
||
if "page" in request.query_params
|
||
else None,
|
||
int(request.query_params["count"])
|
||
if "count" in request.query_params
|
||
else None,
|
||
)
|
||
assert page is not None and page_size is not None
|
||
start = (page - 1) * page_size
|
||
paged_items = items[start : start + page_size]
|
||
if isinstance(payload, dict):
|
||
payload["data"] = paged_items
|
||
else:
|
||
payload = paged_items
|
||
items = paged_items
|
||
response.body = json.dumps(
|
||
payload,
|
||
ensure_ascii=False,
|
||
separators=(",", ":"),
|
||
).encode("utf-8")
|
||
response.headers["content-length"] = str(len(response.body))
|
||
elif not self._optional_collection_pagination:
|
||
page = self._request_int_value(request, "page")
|
||
page_size = self._request_int_value(request, "count")
|
||
|
||
if self._optional_collection_pagination:
|
||
response.headers[COLLECTION_TOTAL_HEADER] = str(total_count)
|
||
response.headers[COLLECTION_RESULT_HEADER] = str(len(items))
|
||
if page is not None:
|
||
response.headers[COLLECTION_PAGE_HEADER] = str(page)
|
||
if page_size is not None:
|
||
response.headers[COLLECTION_PAGE_SIZE_HEADER] = str(page_size)
|
||
return response
|
||
|
||
def _request_int_value(
|
||
self,
|
||
request: Request,
|
||
name: str,
|
||
) -> Optional[int]:
|
||
"""读取请求显式值或端点默认整数值,无法解释时不写分页头。"""
|
||
raw_value: Any = request.query_params.get(name)
|
||
if raw_value is None:
|
||
raw_value = self._collection_parameter_defaults.get(name)
|
||
if raw_value is None or isinstance(raw_value, bool):
|
||
return None
|
||
try:
|
||
return int(raw_value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
@staticmethod
|
||
def _merge_error_responses(
|
||
responses: dict[int | str, dict[str, Any]] | None,
|
||
) -> dict[int | str, dict[str, Any]]:
|
||
"""补齐统一错误模型,并保留端点已经显式声明的响应。
|
||
|
||
仍是默认声明的状态码换成不带 model 的副本,交给 ``_attach_shared_error_response_fields``
|
||
在路由构造后接上共享字段;端点显式覆盖的响应原样保留,由 FastAPI 按原逻辑建字段。
|
||
"""
|
||
merged_responses: dict[int | str, dict[str, Any]] = dict(ERROR_RESPONSES)
|
||
merged_responses.update(responses or {})
|
||
return {
|
||
status: (
|
||
_ERROR_RESPONSES_WITHOUT_MODEL[status]
|
||
if isinstance(status, int) and spec is ERROR_RESPONSES.get(status)
|
||
else spec
|
||
)
|
||
for status, spec in merged_responses.items()
|
||
}
|
||
|
||
def _attach_shared_error_response_fields(self) -> None:
|
||
"""把默认错误响应换回原声明并挂上共享字段。
|
||
|
||
``responses`` 保留完整声明,``include_router`` 用它重建路由时按对象身份再次走共享路径;
|
||
FastAPI 生成 OpenAPI 时只从 ``response_fields`` 取 schema,字段名不进入输出。
|
||
"""
|
||
for status, spec in ERROR_RESPONSES.items():
|
||
if self.responses.get(status) is _ERROR_RESPONSES_WITHOUT_MODEL[status]:
|
||
self.responses[status] = spec
|
||
self.response_fields[status] = _SHARED_ERROR_RESPONSE_FIELDS[status]
|
||
|
||
@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)
|