mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 11:27:43 +08:00
415 lines
16 KiB
Python
415 lines
16 KiB
Python
import inspect
|
|
import json
|
|
from functools import wraps
|
|
from typing import Annotated, Any, Awaitable, Callable, Optional, cast, get_args, get_origin
|
|
|
|
from fastapi import APIRouter, Query, Request
|
|
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"
|
|
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[response_model]
|
|
endpoint = self._wrap_endpoint(endpoint)
|
|
|
|
kwargs["responses"] = self._merge_error_responses(
|
|
kwargs.get("responses")
|
|
)
|
|
|
|
super().__init__(path=path, endpoint=endpoint, **kwargs)
|
|
|
|
def get_route_handler(
|
|
self,
|
|
) -> Callable[[Request], Awaitable[StarletteResponse]]:
|
|
"""在标准端点序列化后附加兼容列表分页与数量元数据。"""
|
|
original_handler = cast(
|
|
Callable[[Request], Awaitable[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]]:
|
|
"""补齐统一错误模型,并保留端点已经显式声明的响应。"""
|
|
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)
|