feat(api): add unified v2 response layer

This commit is contained in:
jxxghp
2026-08-06 17:31:00 +08:00
parent 57220c93db
commit 987c1722d7
13 changed files with 630 additions and 25 deletions

7
app/api/apiv2.py Normal file
View File

@@ -0,0 +1,7 @@
from fastapi import APIRouter
from app.api.apiv1 import api_router
api_router_v2 = APIRouter()
api_router_v2.include_router(api_router)

224
app/api/apiv2_utils.py Normal file
View File

@@ -0,0 +1,224 @@
import json
from typing import Any, Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response as StarletteResponse
from app.schemas.response import Response
API_V2_STR = "/api/v2"
OPENAPI_V2_PATH = f"{API_V2_STR}/openapi.json"
_PROTOCOL_PREFIXES = ("/openai", "/anthropic", "/mcp")
_JSON_CONTENT_TYPES = ("application/json", "+json")
def _is_protocol_path(path: str) -> bool:
"""判断路径是否属于需要保留原始协议响应的接口。"""
relative_path = path.removeprefix(API_V2_STR)
return any(
relative_path == prefix or relative_path.startswith(f"{prefix}/")
for prefix in _PROTOCOL_PREFIXES
)
def _is_json_response(response: StarletteResponse) -> bool:
"""判断响应是否为可安全解析的 JSON 响应。"""
content_type = response.headers.get("content-type", "").split(";", 1)[0]
return any(
content_type == accepted_type or content_type.endswith(accepted_type)
for accepted_type in _JSON_CONTENT_TYPES
)
def _is_response_payload(payload: Any) -> bool:
"""判断响应内容是否已经符合通用 Response 结构。"""
return isinstance(payload, dict) and {
"success",
"message",
"data",
}.issubset(payload)
def _get_error_message(payload: Any) -> str:
"""从旧版错误响应中提取统一的错误消息。"""
if isinstance(payload, dict):
detail = payload.get("detail")
if isinstance(detail, str) and detail:
return detail
if isinstance(detail, list):
messages = [
item.get("msg")
for item in detail
if isinstance(item, dict) and isinstance(item.get("msg"), str)
]
if messages:
return "; ".join(messages)
if detail is not None:
return json.dumps(detail, ensure_ascii=False)
message = payload.get("message")
if isinstance(message, str) and message:
return message
if isinstance(payload, str) and payload:
return payload
return "请求失败"
def _copy_response_headers(source: StarletteResponse, target: StarletteResponse) -> None:
"""复制适配前响应中仍然有效的头信息。"""
for key, value in source.raw_headers:
if key.lower() not in {b"content-length", b"content-type"}:
target.raw_headers.append((key, value))
def _restore_response_body(
source: StarletteResponse,
body: bytes,
) -> StarletteResponse:
"""在检查响应体后恢复原始响应内容和头信息。"""
restored_response = StarletteResponse(
content=body,
status_code=source.status_code,
background=source.background,
)
restored_response.raw_headers = list(source.raw_headers)
return restored_response
class V2ResponseMiddleware(BaseHTTPMiddleware):
"""
为 v2 REST 接口适配统一的 Response 响应结构。
已经返回项目 Response 模型的成功响应保持原样,避免改变既有接口语义;
OpenAI、Anthropic 和 MCP 协议接口也保持原始协议响应。
"""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[StarletteResponse]],
) -> StarletteResponse:
"""处理 v2 请求并在必要时封装 JSON 响应。"""
response = await call_next(request)
if not request.url.path.startswith(f"{API_V2_STR}/"):
return response
if request.url.path == OPENAPI_V2_PATH:
return response
if _is_protocol_path(request.url.path):
return response
if response.status_code in {204, 304} or not _is_json_response(response):
return response
if response.headers.get("content-encoding"):
return response
route = request.scope.get("route")
route_response_model = getattr(route, "response_model", None)
if response.status_code < 400 and route_response_model is Response:
return response
body = b"".join([chunk async for chunk in response.body_iterator])
if not body:
return _restore_response_body(response, body)
try:
payload = json.loads(body)
except (TypeError, ValueError):
return _restore_response_body(response, body)
if _is_response_payload(payload):
return _restore_response_body(response, body)
if response.status_code >= 400:
content = {
"success": False,
"message": _get_error_message(payload),
"data": {},
}
if isinstance(payload, dict) and isinstance(payload.get("detail_i18n"), str):
content["message_i18n"] = payload["detail_i18n"]
else:
content = {
"success": True,
"message": "",
"data": payload,
}
wrapped_response = JSONResponse(
content=content,
status_code=response.status_code,
background=response.background,
)
_copy_response_headers(response, wrapped_response)
return wrapped_response
def configure_v2_openapi(app: FastAPI) -> None:
"""
将 v2 普通 JSON 接口的 OpenAPI 响应模型改为通用 Response。
:param app: 已完成 v1/v2 路由注册的 FastAPI 应用
"""
if getattr(app, "_v2_openapi_configured", False):
return
original_openapi = app.openapi
def custom_openapi() -> dict[str, Any]:
"""生成包含 v2 通用响应模型的 OpenAPI 文档。"""
schema = original_openapi()
components = schema.setdefault("components", {}).setdefault("schemas", {})
components["Response"] = Response.model_json_schema(
ref_template="#/components/schemas/{model}"
)
route_map = {
(route.path, method.lower()): route
for route in app.routes
if isinstance(route, APIRoute)
for method in route.methods
}
response_ref = {"$ref": "#/components/schemas/Response"}
for path, path_item in schema.get("paths", {}).items():
if not path.startswith(f"{API_V2_STR}/"):
continue
for method, operation in path_item.items():
if method not in {
"get",
"post",
"put",
"patch",
"delete",
"options",
"head",
}:
continue
route = route_map.get((path, method))
if (
route is None
or route.response_model is None
or route.response_model is Any
or route.response_model is Response
or _is_protocol_path(path)
):
continue
if route.status_code in {204, 304}:
continue
content_type = getattr(route.response_class, "media_type", None)
if content_type and not (
content_type == "application/json" or content_type.endswith("+json")
):
continue
status_code = str(route.status_code or 200)
response = operation.get("responses", {}).get(status_code)
if response and "content" in response:
json_content = response["content"].get("application/json")
if json_content is not None:
json_content["schema"] = response_ref
app.openapi_schema = schema
return schema
app.openapi = custom_openapi
app._v2_openapi_configured = True

View File

@@ -90,7 +90,7 @@ def wallpaper() -> Any:
""" """
url = WallpaperHelper().get_wallpaper() url = WallpaperHelper().get_wallpaper()
if url: if url:
return schemas.Response(success=True, message=url) return schemas.Response(success=True, data=url)
return schemas.Response(success=False) return schemas.Response(success=False)

View File

@@ -11,6 +11,7 @@ from starlette import status
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
from app import schemas from app import schemas
from app.api.apiv2_utils import API_V2_STR, OPENAPI_V2_PATH
from app.command import Command from app.command import Command
from app.core.cache import async_fresh from app.core.cache import async_fresh
from app.core.config import settings from app.core.config import settings
@@ -36,8 +37,15 @@ from app.scheduler import Scheduler
from app.schemas.event import PluginDataResetEventData from app.schemas.event import PluginDataResetEventData
from app.schemas.types import ChainEventType, SystemConfigKey from app.schemas.types import ChainEventType, SystemConfigKey
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"} PROTECTED_ROUTES = {
"/api/v1/openapi.json",
OPENAPI_V2_PATH,
"/docs",
"/docs/oauth2-redirect",
"/redoc",
}
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin" PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
PLUGIN_V2_PREFIX = f"{API_V2_STR}/plugin"
router = APIRouter() router = APIRouter()
_plugin_release_refresh_tasks: set[asyncio.Task] = set() _plugin_release_refresh_tasks: set[asyncio.Task] = set()
@@ -158,8 +166,11 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
elif Depends(verify_apikey) not in dependencies: elif Depends(verify_apikey) not in dependencies:
dependencies.append(Depends(verify_apikey)) dependencies.append(Depends(verify_apikey))
app.add_api_route(**api, tags=["plugin"]) app.add_api_route(**api, tags=["plugin"])
v2_api = api.copy()
v2_api["path"] = api_path.replace(PLUGIN_PREFIX, PLUGIN_V2_PREFIX, 1)
app.add_api_route(**v2_api, tags=["plugin"])
is_modified = True is_modified = True
logger.debug(f"Added plugin route: {api_path}") logger.debug(f"Added plugin routes: {api_path}, {v2_api['path']}")
except Exception as e: except Exception as e:
logger.error(f"Error adding plugin route {api_path}: {str(e)}") logger.error(f"Error adding plugin route {api_path}: {str(e)}")
@@ -177,8 +188,13 @@ def _remove_routes(plugin_id: str) -> bool:
""" """
if not plugin_id: if not plugin_id:
return False return False
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/" prefixes = {
routes_to_remove = [route for route in app.routes if route.path.startswith(prefix)] f"{PLUGIN_PREFIX}/{plugin_id}/",
f"{PLUGIN_V2_PREFIX}/{plugin_id}/",
}
routes_to_remove = [
route for route in app.routes if any(route.path.startswith(prefix) for prefix in prefixes)
]
removed = False removed = False
for route in routes_to_remove: for route in routes_to_remove:
try: try:

View File

@@ -1,12 +1,27 @@
from typing import Awaitable, Callable import json
from typing import Any, Awaitable, Callable
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.api.apiv2_utils import OPENAPI_V2_PATH, V2ResponseMiddleware
from app.core.config import settings from app.core.config import settings
from app.helper.locale import LocaleHelper from app.helper.locale import LocaleHelper
from app.startup.lifecycle import lifespan from app.startup.lifecycle import lifespan
from version import APP_VERSION
def _get_http_exception_message(detail: Any) -> str:
"""将 HTTPException 的 detail 转换为统一消息文本。"""
if isinstance(detail, str) and detail:
return detail
if detail is None:
return "请求失败"
try:
return json.dumps(detail, ensure_ascii=False)
except TypeError:
return str(detail)
async def localized_http_exception_handler( async def localized_http_exception_handler(
@@ -14,18 +29,20 @@ async def localized_http_exception_handler(
exc: HTTPException, exc: HTTPException,
) -> JSONResponse: ) -> JSONResponse:
""" """
HTTPException 响应补充多语言错误详情 HTTPException 响应统一封装为 Response 结构并保留原始错误消息
:param _request: 当前 HTTP 请求 :param _request: 当前 HTTP 请求
:param exc: FastAPI HTTP 异常 :param exc: FastAPI HTTP 异常
:return: 带 detail_i18n 的 JSON 错误响应 :return: 统一 JSON 错误响应
""" """
content = {"detail": exc.detail} message = _get_http_exception_message(exc.detail)
if isinstance(exc.detail, str):
content["detail_i18n"] = LocaleHelper.translate_text(exc.detail)
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content=content, content={
"success": False,
"message": message,
"data": {},
},
headers=exc.headers, headers=exc.headers,
) )
@@ -36,10 +53,16 @@ def create_app() -> FastAPI:
""" """
_app = FastAPI( _app = FastAPI(
title=settings.PROJECT_NAME, title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json", version=APP_VERSION,
openapi_url=OPENAPI_V2_PATH,
lifespan=lifespan lifespan=lifespan
) )
@_app.get(f"{settings.API_V1_STR}/openapi.json", include_in_schema=False)
def get_v1_openapi_schema() -> dict[str, Any]:
"""保留旧版 OpenAPI 地址并返回当前完整接口文档。"""
return _app.openapi()
_app.add_exception_handler(HTTPException, localized_http_exception_handler) _app.add_exception_handler(HTTPException, localized_http_exception_handler)
# 配置 CORS 中间件 # 配置 CORS 中间件
@@ -50,6 +73,7 @@ def create_app() -> FastAPI:
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
_app.add_middleware(V2ResponseMiddleware)
@_app.middleware("http") @_app.middleware("http")
async def locale_context_middleware( async def locale_context_middleware(

View File

@@ -1,4 +1,4 @@
from typing import Optional, Union from typing import Any, Optional
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
@@ -15,7 +15,7 @@ class Response(BaseModel):
# 多语言消息文本 # 多语言消息文本
message_i18n: Optional[str] = None message_i18n: Optional[str] = None
# 数据 # 数据
data: Optional[Union[dict, list]] = Field(default_factory=dict) data: Optional[Any] = Field(default_factory=dict)
@model_validator(mode="after") @model_validator(mode="after")
def fill_message_i18n(self) -> "Response": def fill_message_i18n(self) -> "Response":

View File

@@ -8,10 +8,15 @@ def init_routers(app: FastAPI):
初始化路由 初始化路由
""" """
from app.api.apiv1 import api_router from app.api.apiv1 import api_router
from app.api.apiv2 import api_router_v2
from app.api.apiv2_utils import API_V2_STR, configure_v2_openapi
from app.api.servarr import arr_router from app.api.servarr import arr_router
from app.api.servcookie import cookie_router from app.api.servcookie import cookie_router
# API路由 # API路由
app.include_router(api_router, prefix=settings.API_V1_STR) app.include_router(api_router, prefix=settings.API_V1_STR)
# v2 API复用v1路由仅在响应出口统一封装
app.include_router(api_router_v2, prefix=API_V2_STR)
configure_v2_openapi(app)
# Radarr、Sonarr路由 # Radarr、Sonarr路由
app.include_router(arr_router, prefix="/api/v3") app.include_router(arr_router, prefix="/api/v3")
# CookieCloud路由 # CookieCloud路由

View File

@@ -112,9 +112,23 @@ MoviePilot 的内置 Agent 也可以作为 MCP Client 连接外部 MCP 服务器
MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所有接口同样需要 API KEY 认证,在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>` MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所有接口同样需要 API KEY 认证,在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`
标准 REST 响应包含 `success``message``message_i18n``data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US``Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。 #### REST API 版本
FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返回 `detail_i18n`;新版前端优先展示 `detail_i18n`,缺失时回退 `detail` - `/api/v1` 默认保持原有响应结构,已有客户端无需迁移;登录壁纸接口的 URL 已统一放入 `data`
- `/api/v2` 复用 `/api/v1` 的同一套路由、请求参数、鉴权依赖和业务实现,只统一普通 JSON 响应结构。
- v1 中已经使用通用 `Response` 的接口在 v2 中保持原样;其他成功 JSON 响应转换为 `{"success": true, "message": "", "data": <原响应>}`
- HTTP 错误保留原状态码,并统一返回 `{"success": false, "message": <错误详情>, "data": {}}`;非业务异常不做多语言翻译。
- SSE、文件、图片、空响应以及 OpenAI、Anthropic、MCP 等标准协议接口保持原始响应格式,不进行通用封装。
因此,普通 REST 接口可将文档中的 `/api/v1/...` 路径直接替换为 `/api/v2/...`。例如 `/api/v1/download/` 对应 `/api/v2/download/`
通用 REST 响应包含 `success``message``message_i18n``data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US``Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。
`GET /api/v1/login/wallpaper` 及对应的 v2 路径会将壁纸 URL 放在 `data` 字段中,`message` 不再承载业务数据。
FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶层 `detail` / `detail_i18n`
交互式接口文档 `/docs` 默认读取 `/api/v2/openapi.json`,页面版本号直接使用 `version.py` 中的后端 `APP_VERSION`。旧地址 `/api/v1/openapi.json` 继续保留并返回同一份完整接口文档。
#### 媒体识别 / 整理 #### 媒体识别 / 整理

View File

@@ -1,6 +1,6 @@
--- ---
name: moviepilot-api name: moviepilot-api
version: 7 version: 8
description: >- description: >-
Use this skill when you need to call MoviePilot REST API endpoints directly Use this skill when you need to call MoviePilot REST API endpoints directly
with the bundled Python client. Covers MoviePilot HTTP endpoints across media with the bundled Python client. Covers MoviePilot HTTP endpoints across media
@@ -65,6 +65,25 @@ python scripts/mp-api.py <METHOD> <PATH> [key=value ...] [--json '<body>']
- Both methods validate against the same `API_TOKEN` value. - Both methods validate against the same `API_TOKEN` value.
- Never print, summarize, or ask the user to paste the API key unless the script is being used outside the local project and no safer configuration source is available. - Never print, summarize, or ask the user to paste the API key unless the script is being used outside the local project and no safer configuration source is available.
### API versions and response envelopes
- `/api/v1` preserves the existing endpoint-specific response shapes by
default; the login wallpaper URL is now returned in `data`.
- `/api/v2` reuses the same routes, parameters, authentication dependencies,
and business handlers, but wraps ordinary JSON responses in the shared
`Response` envelope.
- A successful raw v1 payload becomes
`{"success":true,"message":"","data":<original payload>}` in v2.
- Existing `Response` payloads are not wrapped again. HTTP errors on both v1
and v2 keep their original status code and expose the error text in
`message` with `data={}`. Non-business HTTP exceptions are not translated.
- SSE, files, images, empty responses, and OpenAI, Anthropic, or MCP protocol
endpoints keep their protocol-native response body.
Use `/api/v2` for app clients that require one JSON envelope. Any ordinary
REST path listed below can switch from `/api/v1/...` to `/api/v2/...` without
changing its method, parameters, request body, or authentication.
### Examples ### Examples
```bash ```bash
@@ -79,6 +98,9 @@ python scripts/mp-api.py DELETE /api/v1/subscribe/123
# Endpoints that require ?token= auth # Endpoints that require ?token= auth
python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param
# Uniform v2 JSON response envelope
python scripts/mp-api.py GET /api/v2/dashboard/cpu
``` ```
## Complete API Reference ## Complete API Reference
@@ -488,7 +510,7 @@ The list endpoint returns local cache totals plus `shared_recognized` and
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| POST | `/api/v1/login/access-token` | Get JWT access token. Body: form (username, password) | | POST | `/api/v1/login/access-token` | Get JWT access token. Body: form (username, password) |
| GET | `/api/v1/login/wallpaper` | Login page wallpaper | | GET | `/api/v1/login/wallpaper` | Login page wallpaper; URL is returned in `data` |
| GET | `/api/v1/login/wallpapers` | Login page wallpaper list | | GET | `/api/v1/login/wallpapers` | Login page wallpaper list |
### MCP Tools (6 endpoints) ### MCP Tools (6 endpoints)

259
tests/test_api_v2.py Normal file
View File

@@ -0,0 +1,259 @@
from types import SimpleNamespace
import httpx
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from app.api.apiv2_utils import (
OPENAPI_V2_PATH,
V2ResponseMiddleware,
configure_v2_openapi,
)
from app.schemas.response import Response
pytestmark = pytest.mark.anyio
@pytest.fixture()
def anyio_backend():
"""使用 asyncio 运行异步接口测试。"""
return "asyncio"
@pytest.fixture()
def api_app() -> FastAPI:
"""构造同时包含 v1 和 v2 示例接口的测试应用。"""
app = FastAPI()
app.add_middleware(V2ResponseMiddleware)
@app.get("/api/v1/items")
async def get_v1_items() -> list[dict]:
"""返回未封装的 v1 列表。"""
return [{"id": 1}]
@app.get("/api/v2/items")
async def get_v2_items() -> list[dict]:
"""返回供 v2 适配器封装的列表。"""
return [{"id": 1}]
@app.get("/api/v2/wrapped", response_model=Response)
async def get_wrapped_response() -> Response:
"""返回已经使用通用结构封装的响应。"""
return Response(success=True, message="操作成功", data={"id": 1})
@app.get("/api/v2/error")
async def get_error() -> None:
"""返回供 v2 适配器转换的 HTTP 错误。"""
raise HTTPException(status_code=400, detail="请求参数错误")
@app.get("/api/v2/validated/{item_id}")
async def get_validated_item(item_id: int) -> dict:
"""返回带路径参数校验的示例数据。"""
return {"id": item_id}
@app.get("/api/v2/openai/v1/models")
async def get_openai_models() -> dict:
"""返回需要保持原始协议结构的 OpenAI 模型列表。"""
return {"object": "list", "data": []}
@app.get("/api/v2/events")
async def get_events() -> None:
"""返回不应封装的 SSE 流。"""
async def event_source():
"""生成一条测试事件。"""
yield "data: ok\n\n"
return StreamingResponse(event_source(), media_type="text/event-stream")
@app.get(OPENAPI_V2_PATH, include_in_schema=False)
async def get_v2_openapi_schema() -> dict:
"""返回不应被 v2 中间件封装的 OpenAPI 文档。"""
return {"openapi": "3.1.0", "info": {"title": "Test", "version": "1.0.0"}}
configure_v2_openapi(app)
return app
def make_client(app: FastAPI) -> httpx.AsyncClient:
"""创建不访问真实网络的 ASGI 测试客户端。"""
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
)
async def test_v2_wraps_raw_json_without_changing_v1(api_app: FastAPI):
"""v2 应封装原始 JSON 数据,同时保持 v1 返回结构不变。"""
async with make_client(api_app) as client:
v1_response = await client.get("/api/v1/items")
v2_response = await client.get("/api/v2/items")
assert v1_response.json() == [{"id": 1}]
assert v2_response.json() == {
"success": True,
"message": "",
"data": [{"id": 1}],
}
async def test_v2_keeps_existing_response_payload(api_app: FastAPI):
"""已经使用 Response 的接口不应被重复封装。"""
async with make_client(api_app) as client:
response = await client.get("/api/v2/wrapped")
payload = response.json()
assert payload["success"] is True
assert payload["message"] == "操作成功"
assert payload["data"] == {"id": 1}
assert "success" not in payload["data"]
async def test_v2_moves_http_error_detail_to_message(api_app: FastAPI):
"""v2 HTTP 错误应保留状态码并把 detail 转换到 message。"""
async with make_client(api_app) as client:
response = await client.get("/api/v2/error")
assert response.status_code == 400
assert response.json() == {
"success": False,
"message": "请求参数错误",
"data": {},
}
async def test_v2_moves_validation_error_to_message(api_app: FastAPI):
"""v2 参数校验错误也应返回可直接展示的 message。"""
async with make_client(api_app) as client:
response = await client.get("/api/v2/validated/not-an-integer")
payload = response.json()
assert response.status_code == 422
assert payload["success"] is False
assert payload["message"]
assert payload["data"] == {}
async def test_v2_keeps_protocol_response_unwrapped(api_app: FastAPI):
"""OpenAI 等标准协议接口应保持原始响应结构。"""
async with make_client(api_app) as client:
response = await client.get("/api/v2/openai/v1/models")
assert response.json() == {"object": "list", "data": []}
async def test_v2_keeps_streaming_response_unwrapped(api_app: FastAPI):
"""v2 SSE 等非 JSON 响应应保持原始内容。"""
async with make_client(api_app) as client:
response = await client.get("/api/v2/events")
assert response.headers["content-type"].startswith("text/event-stream")
assert response.text == "data: ok\n\n"
async def test_v2_keeps_openapi_schema_unwrapped(api_app: FastAPI):
"""v2 OpenAPI 文档应保持 Swagger UI 可读取的原始结构。"""
async with make_client(api_app) as client:
response = await client.get(OPENAPI_V2_PATH)
assert response.json() == {
"openapi": "3.1.0",
"info": {"title": "Test", "version": "1.0.0"},
}
def test_v2_openapi_uses_response_schema(api_app: FastAPI):
"""v2 普通 JSON 接口的 OpenAPI 应声明通用 Response 模型。"""
schema = api_app.openapi()
raw_schema = schema["paths"]["/api/v2/items"]["get"]["responses"]["200"]
protocol_schema = schema["paths"]["/api/v2/openai/v1/models"]["get"]
stream_schema = schema["paths"]["/api/v2/events"]["get"]
assert raw_schema["content"]["application/json"]["schema"] == {
"$ref": "#/components/schemas/Response"
}
assert protocol_schema["responses"]["200"]["content"]["application/json"][
"schema"
] != {"$ref": "#/components/schemas/Response"}
assert stream_schema["responses"]["200"]["content"]["application/json"].get(
"schema"
) != {"$ref": "#/components/schemas/Response"}
data_schema = schema["components"]["schemas"]["Response"]["properties"]["data"]
assert data_schema["title"] == "Data"
assert {} in data_schema["anyOf"]
def test_response_accepts_scalar_data():
"""通用 Response 的 data 应支持标量接口数据。"""
response = Response(success=True, data=1)
assert response.data == 1
def test_v2_router_reuses_v1_route_endpoints():
"""v2 路由应直接复用 v1 的端点函数,避免复制业务实现。"""
from fastapi.routing import APIRoute
from app.api.apiv1 import api_router
from app.api.apiv2 import api_router_v2
v1_routes = [route for route in api_router.routes if isinstance(route, APIRoute)]
v2_routes = [route for route in api_router_v2.routes if isinstance(route, APIRoute)]
assert len(v2_routes) == len(v1_routes)
assert all(
v2_route.path == v1_route.path
and v2_route.methods == v1_route.methods
and v2_route.endpoint is v1_route.endpoint
for v1_route, v2_route in zip(v1_routes, v2_routes)
)
def test_plugin_routes_are_mirrored_to_v2(monkeypatch):
"""插件动态路由注册和移除时应同步维护 v2 路径。"""
from app.api.endpoints import plugin as plugin_endpoint
class FakeApp:
"""记录动态注册路径的应用桩。"""
def __init__(self):
self.routes = []
self.openapi_schema = None
def add_api_route(self, **kwargs):
"""记录新增的路由路径。"""
self.routes.append(SimpleNamespace(path=kwargs["path"]))
def setup(self):
"""模拟 FastAPI 路由重建。"""
class FakePluginManager:
"""返回单个测试插件 API 的管理器桩。"""
def get_plugin_apis(self, plugin_id):
"""返回测试插件 API。"""
assert plugin_id == "DemoPlugin"
return [
{
"path": "/DemoPlugin/health",
"endpoint": lambda: {"ok": True},
"methods": ["GET"],
}
]
fake_app = FakeApp()
monkeypatch.setattr(plugin_endpoint, "app", fake_app)
monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager)
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="add")
assert [route.path for route in fake_app.routes] == [
"/api/v1/plugin/DemoPlugin/health",
"/api/v2/plugin/DemoPlugin/health",
]
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="remove")
assert fake_app.routes == []

View File

@@ -23,7 +23,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。""" """本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
expected_versions = { expected_versions = {
"database-operation": "3", "database-operation": "3",
"moviepilot-api": "7", "moviepilot-api": "8",
"moviepilot-cli": "6", "moviepilot-cli": "6",
"moviepilot-update": "3", "moviepilot-update": "3",
"transfer-failed-retry": "2", "transfer-failed-retry": "2",

View File

@@ -6,11 +6,12 @@ from types import SimpleNamespace
from fastapi import HTTPException from fastapi import HTTPException
from app.factory import localized_http_exception_handler from app.factory import create_app, localized_http_exception_handler
from app.helper.locale import LocaleHelper from app.helper.locale import LocaleHelper
from app.helper.progress import ProgressHelper from app.helper.progress import ProgressHelper
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
from app.schemas.response import Response from app.schemas.response import Response
from version import APP_VERSION
def _has_chinese(text: str) -> bool: def _has_chinese(text: str) -> bool:
@@ -247,8 +248,8 @@ def test_response_auto_fills_message_i18n_from_locale_context():
assert response.message_i18n == "Module does not support testing" assert response.message_i18n == "Module does not support testing"
def test_http_exception_handler_adds_detail_i18n_from_locale_context(): def test_http_exception_handler_returns_untranslated_response_envelope():
"""HTTPException 响应应补充多语言 detail 字段""" """HTTPException 响应应统一封装并保留原始错误文本"""
token = LocaleHelper.set_current_locale("en-US") token = LocaleHelper.set_current_locale("en-US")
try: try:
response = asyncio.run( response = asyncio.run(
@@ -260,9 +261,22 @@ def test_http_exception_handler_adds_detail_i18n_from_locale_context():
finally: finally:
LocaleHelper.reset_current_locale(token) LocaleHelper.reset_current_locale(token)
assert response.status_code == 401
payload = json.loads(response.body) payload = json.loads(response.body)
assert payload["detail"] == "用户名或密码错误" assert payload == {
assert payload["detail_i18n"] == "Incorrect username or password" "success": False,
"message": "用户名或密码错误",
"data": {},
}
def test_application_docs_use_v2_openapi_and_backend_version():
"""默认接口文档应展示 v2 地址并使用真实后端版本号。"""
app = create_app()
assert app.openapi_url == "/api/v2/openapi.json"
assert app.version == APP_VERSION
assert "/api/v1/openapi.json" in {route.path for route in app.routes}
def test_progress_helper_get_adds_i18n_fields_without_mutating_cache(): def test_progress_helper_get_adds_i18n_fields_without_mutating_cache():

View File

@@ -95,3 +95,23 @@ def test_login_invalid_password_does_not_expose_mfa_methods(monkeypatch):
assert exc_info.value.status_code == 401 assert exc_info.value.status_code == 401
assert exc_info.value.detail == "用户名或密码错误" assert exc_info.value.detail == "用户名或密码错误"
assert "X-MFA-Required" not in (exc_info.value.headers or {}) assert "X-MFA-Required" not in (exc_info.value.headers or {})
def test_wallpaper_returns_url_in_data(monkeypatch):
"""登录壁纸地址应放入 datamessage 只保留消息文本。"""
class FakeWallpaperHelper:
"""返回固定登录壁纸地址。"""
def get_wallpaper(self):
"""返回测试壁纸地址。"""
return "https://images.example/wallpaper.jpg"
monkeypatch.setattr(login_endpoint, "WallpaperHelper", FakeWallpaperHelper)
response = login_endpoint.wallpaper()
assert response.success is True
assert response.data == "https://images.example/wallpaper.jpg"
assert response.message is None
assert response.message_i18n is None