mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 21:07:01 +08:00
fix(plugin): keep API docs routes across plugin route updates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
InfinityPacer
parent
323c457bf9
commit
8d7d4c6121
@@ -37,7 +37,6 @@ class FastAPIDynamicRouteRegistry:
|
||||
verify_token: Callable[..., Any],
|
||||
verify_apikey: Callable[..., Any],
|
||||
prefix: str,
|
||||
protected_routes: set[str],
|
||||
log: Any,
|
||||
event_loop: Callable[[], asyncio.AbstractEventLoop | None] | None = None,
|
||||
) -> None:
|
||||
@@ -48,7 +47,6 @@ class FastAPIDynamicRouteRegistry:
|
||||
self._verify_token = verify_token
|
||||
self._verify_apikey = verify_apikey
|
||||
self._prefix = prefix
|
||||
self._protected_routes = protected_routes
|
||||
self._logger = log
|
||||
self._event_loop = event_loop
|
||||
|
||||
@@ -111,11 +109,6 @@ class FastAPIDynamicRouteRegistry:
|
||||
|
||||
modified = False
|
||||
removed = False
|
||||
existing_paths = {
|
||||
path: route
|
||||
for route in self._app.routes
|
||||
if (path := self._route_path(route)) is not None
|
||||
}
|
||||
plugin_ids = [plugin_id] if plugin_id else self._plugin_ids()
|
||||
for current_id in plugin_ids:
|
||||
if self.remove(current_id):
|
||||
@@ -150,9 +143,8 @@ class FastAPIDynamicRouteRegistry:
|
||||
f"Error adding plugin route {api_path}: {str(error)}"
|
||||
)
|
||||
if modified:
|
||||
self.clean(existing_paths)
|
||||
# 文档路由由 app.adapters.web.docs 一次性注册,这里只让已缓存的文档按新路由表重建。
|
||||
self._app.openapi_schema = None
|
||||
self._app.setup()
|
||||
if removed:
|
||||
# 旧路由已不在路由表中,此后不会再有请求把旧端点写回缓存
|
||||
_clear_fastapi_callable_caches()
|
||||
@@ -184,15 +176,3 @@ class FastAPIDynamicRouteRegistry:
|
||||
"""返回公开路由路径,跳过 FastAPI 内部的无路径 include 包装器。"""
|
||||
path = getattr(route, "path", None)
|
||||
return path if isinstance(path, str) else None
|
||||
|
||||
def clean(self, existing_paths: dict) -> None:
|
||||
"""清理 FastAPI 重建时可能重复的受保护文档路由。"""
|
||||
for protected_route in self._protected_routes:
|
||||
try:
|
||||
existing_route = existing_paths.get(protected_route)
|
||||
if existing_route:
|
||||
self._app.routes.remove(existing_route)
|
||||
except Exception as error:
|
||||
self._logger.error(
|
||||
f"Error removing protected route {protected_route}: {str(error)}"
|
||||
)
|
||||
|
||||
@@ -404,12 +404,6 @@ def create_app() -> FastAPI:
|
||||
verify_token=verify_token,
|
||||
verify_apikey=verify_apikey,
|
||||
prefix=f"{get_runtime_setting('API_V1_STR')}/plugin",
|
||||
protected_routes={
|
||||
openapi_path,
|
||||
"/docs",
|
||||
"/docs/oauth2-redirect",
|
||||
"/redoc",
|
||||
},
|
||||
log=logger,
|
||||
event_loop=lambda: main_loop_registry.current,
|
||||
))
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.application.plugin import routes as plugin_routes
|
||||
from app.application.settings.contract import build_setting_specs
|
||||
from app.factory import create_app
|
||||
from app.runtime.config import settings
|
||||
@@ -44,6 +45,29 @@ def test_api_docs_follow_runtime_toggle_without_restart(monkeypatch: pytest.Monk
|
||||
assert app.openapi_schema is None
|
||||
|
||||
|
||||
def test_api_docs_survive_plugin_route_updates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""插件路由增删后文档路由仍在,FastAPI.setup() 不再补注册它们。"""
|
||||
app = create_app()
|
||||
registry = plugin_routes._get_route_registry()
|
||||
# 测试进程没有运行中的主 loop,改为当前线程同步更新路由表。
|
||||
monkeypatch.setattr(registry, "_event_loop", None)
|
||||
monkeypatch.setattr(registry, "_plugin_apis", lambda _plugin_id: [{
|
||||
"path": "/DemoPlugin/health",
|
||||
"endpoint": lambda: {"ok": True},
|
||||
"methods": ["GET"],
|
||||
"allow_anonymous": True,
|
||||
}])
|
||||
monkeypatch.setattr(settings, "API_DOCS_ENABLE", True)
|
||||
client = TestClient(app)
|
||||
|
||||
registry.update("DemoPlugin", "add")
|
||||
assert client.get("/api/v1/plugin/DemoPlugin/health").json() == {"ok": True}
|
||||
registry.update("DemoPlugin", "remove")
|
||||
|
||||
for path in DOC_PATHS:
|
||||
assert client.get(path).status_code == 200, path
|
||||
|
||||
|
||||
def test_api_docs_pages_keep_reverse_proxy_prefix(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""文档页引用的 OpenAPI 地址带上 root_path,与 FastAPI 自带文档一致。"""
|
||||
monkeypatch.setattr(settings, "API_DOCS_ENABLE", True)
|
||||
|
||||
@@ -1102,7 +1102,6 @@ def test_plugin_routes_only_register_v1(monkeypatch):
|
||||
verify_token=lambda: None,
|
||||
verify_apikey=lambda: None,
|
||||
prefix="/api/v1/plugin",
|
||||
protected_routes=set(),
|
||||
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
|
||||
))
|
||||
|
||||
@@ -1148,7 +1147,6 @@ async def test_plugin_routes_ignore_included_router_wrappers():
|
||||
verify_token=lambda: None,
|
||||
verify_apikey=lambda: None,
|
||||
prefix="/api/v1/plugin",
|
||||
protected_routes=set(),
|
||||
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
|
||||
))
|
||||
|
||||
@@ -1188,18 +1186,12 @@ async def test_plugin_route_updates_run_on_application_event_loop() -> None:
|
||||
main_thread = threading.get_ident()
|
||||
mutation_threads: list[int] = []
|
||||
original_add_api_route = app.router.add_api_route
|
||||
original_setup = app.setup
|
||||
|
||||
def record_add_api_route(*args, **kwargs):
|
||||
mutation_threads.append(threading.get_ident())
|
||||
return original_add_api_route(*args, **kwargs)
|
||||
|
||||
def record_setup() -> None:
|
||||
mutation_threads.append(threading.get_ident())
|
||||
original_setup()
|
||||
|
||||
app.router.add_api_route = record_add_api_route
|
||||
app.setup = record_setup
|
||||
loop = asyncio.get_running_loop()
|
||||
registry = FastAPIDynamicRouteRegistry(
|
||||
app=app,
|
||||
@@ -1213,7 +1205,6 @@ async def test_plugin_route_updates_run_on_application_event_loop() -> None:
|
||||
verify_token=lambda: None,
|
||||
verify_apikey=lambda: None,
|
||||
prefix="/api/v1/plugin",
|
||||
protected_routes=set(),
|
||||
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
|
||||
event_loop=lambda: loop,
|
||||
)
|
||||
@@ -1237,7 +1228,6 @@ def test_plugin_route_update_rejects_stopped_application_loop() -> None:
|
||||
verify_token=lambda: None,
|
||||
verify_apikey=lambda: None,
|
||||
prefix="/api/v1/plugin",
|
||||
protected_routes=set(),
|
||||
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
|
||||
event_loop=lambda: None,
|
||||
)
|
||||
@@ -1261,7 +1251,6 @@ async def test_plugin_route_update_abandons_late_application_loop_callback() ->
|
||||
verify_token=lambda: None,
|
||||
verify_apikey=lambda: None,
|
||||
prefix="/api/v1/plugin",
|
||||
protected_routes=set(),
|
||||
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
|
||||
event_loop=lambda: loop,
|
||||
)
|
||||
@@ -1306,7 +1295,6 @@ async def test_started_plugin_route_update_waits_for_terminal_result() -> None:
|
||||
verify_token=lambda: None,
|
||||
verify_apikey=lambda: None,
|
||||
prefix="/api/v1/plugin",
|
||||
protected_routes=set(),
|
||||
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
|
||||
event_loop=lambda: loop,
|
||||
)
|
||||
@@ -1434,7 +1422,6 @@ def build_plugin_api_app(monkeypatch) -> FastAPI:
|
||||
verify_token=lambda: None,
|
||||
verify_apikey=lambda: None,
|
||||
prefix="/api/v1/plugin",
|
||||
protected_routes=set(),
|
||||
log=SimpleNamespace(debug=lambda *_args: None, error=lambda *_args: None),
|
||||
))
|
||||
plugin_routes.register_plugin_api("DemoPlugin")
|
||||
|
||||
@@ -54,7 +54,6 @@ def test_removed_plugin_route_releases_endpoint_from_fastapi_caches() -> None:
|
||||
verify_token=lambda: None,
|
||||
verify_apikey=lambda: None,
|
||||
prefix="/api/v1/plugin",
|
||||
protected_routes=set(),
|
||||
log=_SILENT_LOG,
|
||||
)
|
||||
registry.update("RouteCacheDemo", "add")
|
||||
|
||||
Reference in New Issue
Block a user