mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
perf(startup): reduce readiness work (#6341)
This commit is contained in:
+7
-36
@@ -1,40 +1,11 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.endpoints import anilist, auth, login, user, webhook, message, agent, site, subscribe, music, \
|
from app.api.router_specs import API_V1_ROUTER_SPECS
|
||||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
|
|
||||||
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa, openai, anthropic, llm, notification
|
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
for spec in API_V1_ROUTER_SPECS:
|
||||||
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
api_router.include_router(
|
||||||
api_router.include_router(user.router, prefix="/user", tags=["user"])
|
spec.router,
|
||||||
api_router.include_router(mfa.router, prefix="/mfa", tags=["mfa"])
|
prefix=spec.prefix,
|
||||||
api_router.include_router(site.router, prefix="/site", tags=["site"])
|
tags=list(spec.tags),
|
||||||
api_router.include_router(message.router, prefix="/message", tags=["message"])
|
)
|
||||||
api_router.include_router(agent.router, prefix="/message/agent", tags=["agent"])
|
|
||||||
api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"])
|
|
||||||
api_router.include_router(subscribe.router, prefix="/subscribe", tags=["subscribe"])
|
|
||||||
api_router.include_router(music.router, prefix="/music", tags=["music"])
|
|
||||||
api_router.include_router(media.router, prefix="/media", tags=["media"])
|
|
||||||
api_router.include_router(search.router, prefix="/search", tags=["search"])
|
|
||||||
api_router.include_router(douban.router, prefix="/douban", tags=["douban"])
|
|
||||||
api_router.include_router(tmdb.router, prefix="/tmdb", tags=["tmdb"])
|
|
||||||
api_router.include_router(history.router, prefix="/history", tags=["history"])
|
|
||||||
api_router.include_router(system.router, prefix="/system", tags=["system"])
|
|
||||||
api_router.include_router(notification.router, prefix="/notification", tags=["notification"])
|
|
||||||
api_router.include_router(llm.router, prefix="/llm", tags=["llm"])
|
|
||||||
api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"])
|
|
||||||
api_router.include_router(download.router, prefix="/download", tags=["download"])
|
|
||||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
|
||||||
api_router.include_router(storage.router, prefix="/storage", tags=["storage"])
|
|
||||||
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
|
|
||||||
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])
|
|
||||||
api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"])
|
|
||||||
api_router.include_router(anilist.router, prefix="/anilist", tags=["anilist"])
|
|
||||||
api_router.include_router(discover.router, prefix="/discover", tags=["discover"])
|
|
||||||
api_router.include_router(recommend.router, prefix="/recommend", tags=["recommend"])
|
|
||||||
api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"])
|
|
||||||
api_router.include_router(torrent.router, prefix="/torrent", tags=["torrent"])
|
|
||||||
api_router.include_router(mcp.router, prefix="/mcp", tags=["mcp"])
|
|
||||||
api_router.include_router(openai.router, prefix="/openai/v1", tags=["openai"])
|
|
||||||
api_router.include_router(anthropic.router, prefix="/anthropic/v1", tags=["anthropic"])
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.endpoints import (
|
||||||
|
agent,
|
||||||
|
anilist,
|
||||||
|
anthropic,
|
||||||
|
auth,
|
||||||
|
bangumi,
|
||||||
|
dashboard,
|
||||||
|
discover,
|
||||||
|
douban,
|
||||||
|
download,
|
||||||
|
history,
|
||||||
|
llm,
|
||||||
|
login,
|
||||||
|
mcp,
|
||||||
|
media,
|
||||||
|
mediaserver,
|
||||||
|
message,
|
||||||
|
mfa,
|
||||||
|
music,
|
||||||
|
notification,
|
||||||
|
openai,
|
||||||
|
plugin,
|
||||||
|
recommend,
|
||||||
|
search,
|
||||||
|
site,
|
||||||
|
storage,
|
||||||
|
subscribe,
|
||||||
|
system,
|
||||||
|
tmdb,
|
||||||
|
torrent,
|
||||||
|
transfer,
|
||||||
|
user,
|
||||||
|
webhook,
|
||||||
|
workflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RouterSpec(NamedTuple):
|
||||||
|
"""声明一个 v1 端点路由器及其公开路径元数据。"""
|
||||||
|
|
||||||
|
router: APIRouter # 原始端点路由器
|
||||||
|
prefix: str # 相对 v1 根路径的前缀
|
||||||
|
tags: tuple[str, ...] # 追加到端点 OpenAPI 操作的标签
|
||||||
|
|
||||||
|
|
||||||
|
API_V1_ROUTER_SPECS: tuple[RouterSpec, ...] = (
|
||||||
|
RouterSpec(auth.router, "/auth", ("auth",)),
|
||||||
|
RouterSpec(login.router, "/login", ("login",)),
|
||||||
|
RouterSpec(user.router, "/user", ("user",)),
|
||||||
|
RouterSpec(mfa.router, "/mfa", ("mfa",)),
|
||||||
|
RouterSpec(site.router, "/site", ("site",)),
|
||||||
|
RouterSpec(message.router, "/message", ("message",)),
|
||||||
|
RouterSpec(agent.router, "/message/agent", ("agent",)),
|
||||||
|
RouterSpec(webhook.router, "/webhook", ("webhook",)),
|
||||||
|
RouterSpec(subscribe.router, "/subscribe", ("subscribe",)),
|
||||||
|
RouterSpec(music.router, "/music", ("music",)),
|
||||||
|
RouterSpec(media.router, "/media", ("media",)),
|
||||||
|
RouterSpec(search.router, "/search", ("search",)),
|
||||||
|
RouterSpec(douban.router, "/douban", ("douban",)),
|
||||||
|
RouterSpec(tmdb.router, "/tmdb", ("tmdb",)),
|
||||||
|
RouterSpec(history.router, "/history", ("history",)),
|
||||||
|
RouterSpec(system.router, "/system", ("system",)),
|
||||||
|
RouterSpec(notification.router, "/notification", ("notification",)),
|
||||||
|
RouterSpec(llm.router, "/llm", ("llm",)),
|
||||||
|
RouterSpec(plugin.router, "/plugin", ("plugin",)),
|
||||||
|
RouterSpec(download.router, "/download", ("download",)),
|
||||||
|
RouterSpec(dashboard.router, "/dashboard", ("dashboard",)),
|
||||||
|
RouterSpec(storage.router, "/storage", ("storage",)),
|
||||||
|
RouterSpec(transfer.router, "/transfer", ("transfer",)),
|
||||||
|
RouterSpec(mediaserver.router, "/mediaserver", ("mediaserver",)),
|
||||||
|
RouterSpec(bangumi.router, "/bangumi", ("bangumi",)),
|
||||||
|
RouterSpec(anilist.router, "/anilist", ("anilist",)),
|
||||||
|
RouterSpec(discover.router, "/discover", ("discover",)),
|
||||||
|
RouterSpec(recommend.router, "/recommend", ("recommend",)),
|
||||||
|
RouterSpec(workflow.router, "/workflow", ("workflow",)),
|
||||||
|
RouterSpec(torrent.router, "/torrent", ("torrent",)),
|
||||||
|
RouterSpec(mcp.router, "/mcp", ("mcp",)),
|
||||||
|
RouterSpec(openai.router, "/openai/v1", ("openai",)),
|
||||||
|
RouterSpec(anthropic.router, "/anthropic/v1", ("anthropic",)),
|
||||||
|
)
|
||||||
@@ -7,11 +7,16 @@ def init_routers(app: FastAPI):
|
|||||||
"""
|
"""
|
||||||
初始化路由
|
初始化路由
|
||||||
"""
|
"""
|
||||||
from app.api.apiv1 import api_router
|
from app.api.router_specs import API_V1_ROUTER_SPECS
|
||||||
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路由
|
# 直接聚合端点路由,避免先构建兼容路由器再克隆到最终应用。
|
||||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
for spec in API_V1_ROUTER_SPECS:
|
||||||
|
app.include_router(
|
||||||
|
spec.router,
|
||||||
|
prefix=f"{settings.API_V1_STR}{spec.prefix}",
|
||||||
|
tags=list(spec.tags),
|
||||||
|
)
|
||||||
# Radarr、Sonarr路由
|
# Radarr、Sonarr路由
|
||||||
app.include_router(arr_router, prefix="/api/v3")
|
app.include_router(arr_router, prefix="/api/v3")
|
||||||
# CookieCloud路由
|
# CookieCloud路由
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter, FastAPI
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from app.api.deps import get_current_active_user_async
|
||||||
|
from app.runtime.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _v1_routes(app: FastAPI) -> list[APIRoute]:
|
||||||
|
"""返回最终应用中的 v1 API 路由。"""
|
||||||
|
return [
|
||||||
|
route
|
||||||
|
for route in app.routes
|
||||||
|
if isinstance(route, APIRoute)
|
||||||
|
and route.path.startswith(f"{settings.API_V1_STR}/")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _route_contract(route: APIRoute) -> tuple[Any, ...]:
|
||||||
|
"""提取直接聚合前后必须保持一致的公开路由合同。"""
|
||||||
|
return (
|
||||||
|
type(route),
|
||||||
|
route.path,
|
||||||
|
tuple(sorted(route.methods or ())),
|
||||||
|
route.name,
|
||||||
|
route.endpoint,
|
||||||
|
tuple(route.tags),
|
||||||
|
route.status_code,
|
||||||
|
route.response_model,
|
||||||
|
route.response_class,
|
||||||
|
route.responses,
|
||||||
|
tuple(
|
||||||
|
(
|
||||||
|
dependency.dependency,
|
||||||
|
dependency.use_cache,
|
||||||
|
tuple(dependency.scopes or ()),
|
||||||
|
)
|
||||||
|
for dependency in route.dependencies
|
||||||
|
),
|
||||||
|
route.operation_id,
|
||||||
|
route.unique_id,
|
||||||
|
route.include_in_schema,
|
||||||
|
route.deprecated,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_routers_directly_includes_endpoint_router_specs(monkeypatch):
|
||||||
|
"""启动聚合应直接 include 原始端点路由器并一次性附加完整 v1 前缀。"""
|
||||||
|
from app.api.router_specs import API_V1_ROUTER_SPECS
|
||||||
|
from app.startup.routers_initializer import init_routers
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
include_calls = []
|
||||||
|
original_include_router = app.include_router
|
||||||
|
|
||||||
|
def record_include_router(router, **kwargs):
|
||||||
|
"""记录启动聚合参数后继续执行 FastAPI 的公开 include 接口。"""
|
||||||
|
include_calls.append((router, kwargs.get("prefix"), kwargs.get("tags")))
|
||||||
|
return original_include_router(router, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(app, "include_router", record_include_router)
|
||||||
|
|
||||||
|
init_routers(app)
|
||||||
|
|
||||||
|
v1_calls = include_calls[: len(API_V1_ROUTER_SPECS)]
|
||||||
|
assert [router for router, _, _ in v1_calls] == [
|
||||||
|
spec.router for spec in API_V1_ROUTER_SPECS
|
||||||
|
]
|
||||||
|
assert [prefix for _, prefix, _ in v1_calls] == [
|
||||||
|
f"{settings.API_V1_STR}{spec.prefix}" for spec in API_V1_ROUTER_SPECS
|
||||||
|
]
|
||||||
|
assert [tuple(tags or ()) for _, _, tags in v1_calls] == [
|
||||||
|
spec.tags for spec in API_V1_ROUTER_SPECS
|
||||||
|
]
|
||||||
|
assert [prefix for _, prefix, _ in include_calls[-2:]] == [
|
||||||
|
"/api/v3",
|
||||||
|
"/cookiecloud",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_v1_routes_and_openapi_match_compatibility_router():
|
||||||
|
"""最终应用的 v1 路由合同与 OpenAPI 应和兼容聚合结果完全一致。"""
|
||||||
|
from app.api.apiv1 import api_router
|
||||||
|
from app.startup.routers_initializer import init_routers
|
||||||
|
|
||||||
|
compatibility_app = FastAPI()
|
||||||
|
compatibility_app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||||
|
direct_app = FastAPI()
|
||||||
|
init_routers(direct_app)
|
||||||
|
|
||||||
|
compatibility_routes = [
|
||||||
|
route
|
||||||
|
for route in compatibility_app.routes
|
||||||
|
if isinstance(route, APIRoute)
|
||||||
|
]
|
||||||
|
direct_routes = _v1_routes(direct_app)
|
||||||
|
|
||||||
|
assert [_route_contract(route) for route in direct_routes] == [
|
||||||
|
_route_contract(route) for route in compatibility_routes
|
||||||
|
]
|
||||||
|
assert all(
|
||||||
|
route.dependency_overrides_provider is direct_app for route in direct_routes
|
||||||
|
)
|
||||||
|
assert all(
|
||||||
|
route.dependency_overrides_provider is compatibility_app
|
||||||
|
for route in compatibility_routes
|
||||||
|
)
|
||||||
|
direct_v1_paths = {
|
||||||
|
path: item
|
||||||
|
for path, item in direct_app.openapi()["paths"].items()
|
||||||
|
if path.startswith(f"{settings.API_V1_STR}/")
|
||||||
|
}
|
||||||
|
assert direct_v1_paths == compatibility_app.openapi()["paths"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_direct_routes_honor_application_dependency_overrides():
|
||||||
|
"""直接聚合后的路由仍应由最终 FastAPI 应用解析依赖覆盖。"""
|
||||||
|
from app.startup.routers_initializer import init_routers
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_routers(app)
|
||||||
|
app.dependency_overrides[get_current_active_user_async] = lambda: object()
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
) as client:
|
||||||
|
response = await client.get(f"{settings.API_V1_STR}/system/ping")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"success": True, "message": "", "data": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_compatibility_api_router_keeps_public_contract():
|
||||||
|
"""历史导出应继续提供无 v1 根前缀的标准 APIRouter 与固定路由集合。"""
|
||||||
|
from app.api.apiv1 import api_router
|
||||||
|
from app.api.router_specs import API_V1_ROUTER_SPECS
|
||||||
|
|
||||||
|
assert type(api_router) is APIRouter
|
||||||
|
assert len(api_router.routes) == sum(
|
||||||
|
len(spec.router.routes) for spec in API_V1_ROUTER_SPECS
|
||||||
|
)
|
||||||
|
assert all(
|
||||||
|
isinstance(route, APIRoute)
|
||||||
|
and route.path.startswith("/")
|
||||||
|
and not route.path.startswith(f"{settings.API_V1_STR}/")
|
||||||
|
for route in api_router.routes
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user